From 47cc8b040ad661cd5d3fd25c89e304be63943b1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Pi=C3=B1era?= Date: Tue, 17 Mar 2026 11:01:29 +0100 Subject: [PATCH 01/19] Replace SwiftNIO with swift-file-system --- Package.resolved | 91 +++++- Package.swift | 18 +- Sources/FileSystem/FileSystem.swift | 452 +++++++++++----------------- 3 files changed, 264 insertions(+), 297 deletions(-) diff --git a/Package.resolved b/Package.resolved index a3b8e43..1b03059 100644 --- a/Package.resolved +++ b/Package.resolved @@ -10,11 +10,11 @@ } }, { - "identity" : "swift-atomics", + "identity" : "swift-async-algorithms-fork", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-atomics.git", + "location" : "https://github.com/coenttb/swift-async-algorithms-fork.git", "state" : { - "revision" : "cd142fd2f64be2100422d658e7411e39489da985", + "revision" : "9352a14c5693451c0f76a433d22dbe86a92f61ae", "version" : "1.2.0" } }, @@ -23,8 +23,26 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "94cf62b3ba8d4bed62680a282d4c25f9c63c2efb", - "version" : "1.1.0" + "revision" : "8d9834a6189db730f6264db7556a7ffb751e99ee", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-file-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/coenttb/swift-file-system", + "state" : { + "revision" : "4e65b651641a816e64f61664dbb357ad519fe05a", + "version" : "0.6.0" + } + }, + { + "identity" : "swift-incits-4-1986", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-standards/swift-incits-4-1986", + "state" : { + "revision" : "5e0ac8ce2e69663d690bc12993c9cebefffae613", + "version" : "0.7.1" } }, { @@ -32,26 +50,71 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log", "state" : { - "revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca", - "version" : "1.8.0" + "revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523", + "version" : "1.10.1" } }, { - "identity" : "swift-nio", + "identity" : "swift-memory-allocation", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio", + "location" : "https://github.com/coenttb/swift-memory-allocation", "state" : { - "revision" : "663ddc80f2081c8f22e417cbac5f80270a93795e", - "version" : "2.91.0" + "revision" : "afe7e86f16981007b841470d5ea79f0026868bc3", + "version" : "0.2.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-rfc-4648", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-standards/swift-rfc-4648", + "state" : { + "revision" : "029f384ec63890d98da9a09844bb2ef91a176872", + "version" : "0.6.0" + } + }, + { + "identity" : "swift-standards", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-standards/swift-standards", + "state" : { + "revision" : "948b7642f57f9aac26f4b6d1e3a86b5c1861ecbb", + "version" : "0.30.1" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" } }, { "identity" : "swift-system", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-system.git", + "location" : "https://github.com/apple/swift-system", "state" : { - "revision" : "c8a44d836fe7913603e246acab7c528c2e780168", - "version" : "1.4.0" + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } + }, + { + "identity" : "swift-testing-performance", + "kind" : "remoteSourceControl", + "location" : "https://github.com/coenttb/swift-testing-performance", + "state" : { + "revision" : "1a1967f7acfcb081e57122db463817c142cc7186", + "version" : "0.3.1" } }, { diff --git a/Package.swift b/Package.swift index 058bbb3..3d9dd1b 100644 --- a/Package.swift +++ b/Package.swift @@ -6,8 +6,6 @@ #if os(Windows) let zipFoundationDependency: [Package.Dependency] = [] let zipFoundationTarget: [Target.Dependency] = [] - let swiftNioDependency: [Package.Dependency] = [] - let swiftNioTarget: [Target.Dependency] = [] #else let zipFoundationDependency: [Package.Dependency] = [ .package(url: "https://github.com/tuist/ZIPFoundation", .upToNextMajor(from: "0.9.20")), @@ -15,19 +13,13 @@ let zipFoundationTarget: [Target.Dependency] = [ .product(name: "ZIPFoundation", package: "ZIPFoundation"), ] - let swiftNioDependency: [Package.Dependency] = [ - .package(url: "https://github.com/apple/swift-nio", .upToNextMajor(from: "2.92.0")), - ] - let swiftNioTarget: [Target.Dependency] = [ - .product(name: "_NIOFileSystem", package: "swift-nio"), - ] #endif let package = Package( name: "FileSystem", platforms: [ - .macOS("13.0"), - .iOS("16.0"), + .macOS("26.0"), + .iOS("26.0"), ], products: [ .library( @@ -47,17 +39,19 @@ let package = Package( ), ], dependencies: [ + .package(url: "https://github.com/coenttb/swift-file-system", .upToNextMajor(from: "0.6.0")), .package(url: "https://github.com/tuist/Path", .upToNextMajor(from: "0.3.8")), .package(url: "https://github.com/apple/swift-log", .upToNextMajor(from: "1.10.1")), - ] + zipFoundationDependency + swiftNioDependency, + ] + zipFoundationDependency, targets: [ .target( name: "FileSystem", dependencies: [ "Glob", + .product(name: "File System", package: "swift-file-system"), .product(name: "Path", package: "Path"), .product(name: "Logging", package: "swift-log"), - ] + zipFoundationTarget + swiftNioTarget, + ] + zipFoundationTarget, swiftSettings: [ .define("MOCKING", .when(configuration: .debug)), ] diff --git a/Sources/FileSystem/FileSystem.swift b/Sources/FileSystem/FileSystem.swift index df1131a..0eb880e 100644 --- a/Sources/FileSystem/FileSystem.swift +++ b/Sources/FileSystem/FileSystem.swift @@ -1,15 +1,18 @@ -#if os(Windows) - import WinSDK -#else - import _NIOFileSystem - import NIOCore -#endif import Foundation +import File_System +import File_System_Primitives import Glob import Logging import Path #if !os(Windows) + #if canImport(Darwin) + import Darwin + #elseif canImport(Glibc) + import Glibc + #elseif canImport(Musl) + import Musl + #endif import ZIPFoundation #endif @@ -367,40 +370,19 @@ public struct FileSystem: FileSysteming, Sendable { } public func currentWorkingDirectory() async throws -> AbsolutePath { - #if os(Windows) - return try AbsolutePath(validating: FileManager.default.currentDirectoryPath) - #else - return try await _NIOFileSystem.FileSystem.shared.currentWorkingDirectory.path - #endif + return try AbsolutePath(validating: FileManager.default.currentDirectoryPath) } public func contentsOfDirectory(_ path: AbsolutePath) async throws -> [AbsolutePath] { - #if os(Windows) - let contents = try FileManager.default.contentsOfDirectory(atPath: path.pathString) - return try contents.map { try path.appending(component: $0) } - #else - return try await _NIOFileSystem.FileSystem.shared.withDirectoryHandle( - atPath: .init(path.pathString) - ) { directory in - try await directory - .listContents() - .reduce(into: []) { $0.append($1) } - .map(\.path) - } - .map(\.path) - #endif + let directory = File.Directory(try systemPath(path)) + return try await File.Directory.Contents.list(at: directory).map { entry in + try absolutePath(entry.path()) + } } public func exists(_ path: AbsolutePath) async throws -> Bool { logger?.debug("Checking if a file or directory exists at path \(path.pathString).") - #if os(Windows) - return path.pathString.withCString(encodedAs: UTF16.self) { pointer in - GetFileAttributesW(pointer) != INVALID_FILE_ATTRIBUTES - } - #else - let info = try await _NIOFileSystem.FileSystem.shared.info(forFileAt: .init(path.pathString)) - return info != nil - #endif + return await File.System.Stat.exists(at: try systemPath(path)) } public func exists(_ path: AbsolutePath, isDirectory: Bool) async throws -> Bool { @@ -409,48 +391,33 @@ public struct FileSystem: FileSysteming, Sendable { } else { logger?.debug("Checking if a file exists at path \(path.pathString).") } - #if os(Windows) - return path.pathString.withCString(encodedAs: UTF16.self) { pointer in - let attributes = GetFileAttributesW(pointer) - guard attributes != INVALID_FILE_ATTRIBUTES else { return false } - let isDir = (attributes & DWORD(FILE_ATTRIBUTE_DIRECTORY)) != 0 - return isDir == isDirectory - } - #else - guard let info = try await _NIOFileSystem.FileSystem.shared.info(forFileAt: .init(path.pathString)) else { - return false - } - return info.type == (isDirectory ? .directory : .regular) - #endif + let filePath = try systemPath(path) + guard await File.System.Stat.exists(at: filePath) else { return false } + let metadata = try await File.System.Stat.info(at: filePath) + let pathIsDirectory = metadata.type == .directory + return pathIsDirectory == isDirectory } public func touch(_ path: Path.AbsolutePath) async throws { logger?.debug("Touching a file at path \(path.pathString).") - #if os(Windows) - FileManager.default.createFile(atPath: path.pathString, contents: Data(), attributes: nil) - #else - // Use non-transactional creation to ensure the file is immediately visible - // to other file system APIs (like Foundation's FileManager/FileHandle). - // The default options use transactionalCreation which only materializes - // the file when the handle is closed, causing visibility issues. - let options = _NIOFileSystem.OpenOptions.Write( - existingFile: .open, - newFile: .init(transactionalCreation: false) - ) - _ = try await _NIOFileSystem.FileSystem.shared.withFileHandle( - forWritingAt: .init(path.pathString), - options: options - ) { _ in } - #endif + if try await exists(path) { + let now = Date() + try await setFileTimes(of: path, lastAccessDate: now, lastModificationDate: now) + return + } + + guard try await exists(path.parentDirectory, isDirectory: true) else { + throw CocoaError(.fileNoSuchFile) + } + + try await writeFile(Data(), to: path) } public func remove(_ path: AbsolutePath) async throws { logger?.debug("Removing the file or directory at path: \(path.pathString).") - guard try await exists(path) else { return } - try await Task { - try FileManager.default.removeItem(atPath: path.pathString) - } - .value + let filePath = try systemPath(path) + guard await File.System.Stat.exists(at: filePath) else { return } + try await File.System.Delete.delete(at: filePath, options: .init(recursive: true)) } public func makeTemporaryDirectory(prefix: String) async throws -> AbsolutePath { @@ -466,9 +433,9 @@ public struct FileSystem: FileSysteming, Sendable { let temporaryDirectory = try AbsolutePath(validating: systemTemporaryDirectory) .appending(component: "\(prefix)-\(UUID().uuidString)") logger?.debug("Creating a temporary directory at path \(temporaryDirectory.pathString).") - try FileManager.default.createDirectory( - at: URL(fileURLWithPath: temporaryDirectory.pathString), - withIntermediateDirectories: true + try await File.System.Create.Directory.create( + at: try systemPath(temporaryDirectory), + options: .init(createIntermediates: true) ) return temporaryDirectory } @@ -491,26 +458,11 @@ public struct FileSystem: FileSysteming, Sendable { try? await makeDirectory(at: to.parentDirectory, options: [.createTargetParentDirectories]) } } - #if os(Windows) - do { - try FileManager.default.moveItem(atPath: from.pathString, toPath: to.pathString) - } catch { - if !FileManager.default.fileExists(atPath: from.pathString) { - throw FileSystemError.moveNotFound(from: from, to: to) - } - throw error - } - #else - do { - try await _NIOFileSystem.FileSystem.shared.moveItem(at: .init(from.pathString), to: .init(to.pathString)) - } catch let error as _NIOFileSystem.FileSystemError { - if error.code == .notFound { - throw FileSystemError.moveNotFound(from: from, to: to) - } else { - throw error - } - } - #endif + let sourcePath = try systemPath(from) + guard await File.System.Stat.exists(at: sourcePath) else { + throw FileSystemError.moveNotFound(from: from, to: to) + } + try await File.System.Move.move(from: sourcePath, to: try systemPath(to)) } public func makeDirectory(at: Path.AbsolutePath) async throws { @@ -526,37 +478,14 @@ public struct FileSystem: FileSysteming, Sendable { } else { logger?.debug("Creating directory at path \(at.pathString).") } - #if os(Windows) - do { - try FileManager.default.createDirectory( - atPath: at.pathString, - withIntermediateDirectories: options.contains(.createTargetParentDirectories), - attributes: nil - ) - } catch { - if !options.contains(.createTargetParentDirectories) { - let parentExists = FileManager.default.fileExists(atPath: at.parentDirectory.pathString) - if !parentExists { - throw FileSystemError.makeDirectoryAbsentParent(at) - } - } - throw error - } - #else - do { - try await _NIOFileSystem.FileSystem.shared.createDirectory( - at: .init(at.pathString), - withIntermediateDirectories: options - .contains(.createTargetParentDirectories) - ) - } catch let error as _NIOFileSystem.FileSystemError { - if error.code == .invalidArgument { - throw FileSystemError.makeDirectoryAbsentParent(at) - } else { - throw error - } - } - #endif + let createIntermediates = options.contains(.createTargetParentDirectories) + if !createIntermediates, !(try await exists(at.parentDirectory, isDirectory: true)) { + throw FileSystemError.makeDirectoryAbsentParent(at) + } + try await File.System.Create.Directory.create( + at: try systemPath(at), + options: .init(createIntermediates: createIntermediates) + ) } public func readFile(at path: Path.AbsolutePath) async throws -> Data { @@ -567,31 +496,7 @@ public struct FileSystem: FileSysteming, Sendable { if log { logger?.debug("Reading file at path \(path.pathString).") } - #if os(Windows) - return try Data(contentsOf: URL(fileURLWithPath: path.pathString)) - #else - let handle = try await _NIOFileSystem.FileSystem.shared.openFile( - forReadingAt: .init(path.pathString), - options: .init() - ) - - let result: Result - do { - var bytes: [UInt8] = [] - for try await var chunk in handle.readChunks() { - let chunkBytes = chunk.readBytes(length: chunk.readableBytes) ?? [] - bytes.append(contentsOf: chunkBytes) - } - result = .success(Data(bytes)) - } catch { - result = .failure(error) - } - try await handle.close() - switch result { - case let .success(data): return data - case let .failure(error): throw error - } - #endif + return Data(try await File.System.Read.Full.read(from: try systemPath(path))) } public func readTextFile(at: Path.AbsolutePath) async throws -> String { @@ -629,14 +534,7 @@ public struct FileSystem: FileSysteming, Sendable { if options.contains(.overwrite), try await exists(path) { try await remove(path) } - - #if os(Windows) - try data.write(to: URL(fileURLWithPath: path.pathString)) - #else - _ = try await _NIOFileSystem.FileSystem.shared.withFileHandle(forWritingAt: .init(path.pathString)) { handler in - try await handler.write(contentsOf: data, toAbsoluteOffset: 0) - } - #endif + try await writeFile(data, to: path) } public func readPlistFile(at path: Path.AbsolutePath) async throws -> T where T: Decodable { @@ -670,13 +568,7 @@ public struct FileSystem: FileSysteming, Sendable { } let plistData = try encoder.encode(item) - #if os(Windows) - try plistData.write(to: URL(fileURLWithPath: path.pathString)) - #else - _ = try await _NIOFileSystem.FileSystem.shared.withFileHandle(forWritingAt: .init(path.pathString)) { handler in - try await handler.write(contentsOf: plistData, toAbsoluteOffset: 0) - } - #endif + try await writeFile(plistData, to: path) } public func readJSONFile(at path: Path.AbsolutePath) async throws -> T where T: Decodable { @@ -709,47 +601,36 @@ public struct FileSystem: FileSysteming, Sendable { if options.contains(.overwrite), try await exists(path) { try await remove(path) } - - #if os(Windows) - try json.write(to: URL(fileURLWithPath: path.pathString)) - #else - _ = try await _NIOFileSystem.FileSystem.shared.withFileHandle(forWritingAt: .init(path.pathString)) { handler in - try await handler.write(contentsOf: json, toAbsoluteOffset: 0) - } - #endif + try await writeFile(json, to: path) } public func replace(_ to: AbsolutePath, with path: AbsolutePath) async throws { logger?.debug("Replacing file or directory at path \(path.pathString) with item at path \(to.pathString).") - if !(try await exists(path)) { + let sourcePath = try systemPath(path) + let destinationPath = try systemPath(to) + if !(await File.System.Stat.exists(at: sourcePath)) { throw FileSystemError.replacingItemAbsent(replacingPath: path, replacedPath: to) } if !(try await exists(to.parentDirectory)) { try await makeDirectory(at: to.parentDirectory) } - #if os(Windows) - if FileManager.default.fileExists(atPath: to.pathString) { - try FileManager.default.removeItem(atPath: to.pathString) - } - try FileManager.default.copyItem(atPath: path.pathString, toPath: to.pathString) - #else - try await _NIOFileSystem.FileSystem.shared.replaceItem(at: .init(to.pathString), withItemAt: .init(path.pathString)) - #endif + if await File.System.Stat.exists(at: destinationPath) { + try await File.System.Delete.delete(at: destinationPath, options: .init(recursive: true)) + } + try await copyItem(from: sourcePath, to: destinationPath) } public func copy(_ from: AbsolutePath, to: AbsolutePath) async throws { logger?.debug("Copying file or directory at path \(from.pathString) to \(to.pathString).") - if !(try await exists(from)) { + let sourcePath = try systemPath(from) + let destinationPath = try systemPath(to) + if !(await File.System.Stat.exists(at: sourcePath)) { throw FileSystemError.copiedItemAbsent(copiedPath: from, intoPath: to) } if !(try await exists(to.parentDirectory)) { try await makeDirectory(at: to.parentDirectory) } - #if os(Windows) - try FileManager.default.copyItem(atPath: from.pathString, toPath: to.pathString) - #else - try await _NIOFileSystem.FileSystem.shared.copyItem(at: .init(from.pathString), to: .init(to.pathString)) - #endif + try await copyItem(from: sourcePath, to: destinationPath) } public func runInTemporaryDirectory( @@ -781,36 +662,16 @@ public struct FileSystem: FileSysteming, Sendable { ) public func fileSizeInBytes(at path: AbsolutePath) async throws -> Int64? { logger?.debug("Getting the size in bytes of file at path \(path.pathString).") - #if os(Windows) - guard let attrs = try? FileManager.default.attributesOfItem(atPath: path.pathString), - let size = attrs[.size] as? Int64 - else { return nil } - return size - #else - guard let info = try await _NIOFileSystem.FileSystem.shared.info( - forFileAt: .init(path.pathString), - infoAboutSymbolicLink: true - ) else { return nil } - return info.size - #endif + guard let attributes = try? FileManager.default.attributesOfItem(atPath: path.pathString) else { return nil } + return Self.fileSize(from: attributes) } public func fileMetadata(at path: AbsolutePath) async throws -> FileMetadata? { logger?.debug("Getting the metadata of file at path \(path.pathString).") - #if os(Windows) - guard let attrs = try? FileManager.default.attributesOfItem(atPath: path.pathString) else { return nil } - let size = (attrs[.size] as? Int64) ?? 0 - let modificationDate = (attrs[.modificationDate] as? Date) ?? Date() - return FileMetadata(size: size, lastModificationDate: modificationDate) - #else - guard let info = try await _NIOFileSystem.FileSystem.shared.info( - forFileAt: .init(path.pathString), - infoAboutSymbolicLink: true - ) else { return nil } - let lastModified = info.lastDataModificationTime - let modificationTimeInterval = Double(lastModified.seconds) + Double(lastModified.nanoseconds) / 1_000_000_000 - return FileMetadata(size: info.size, lastModificationDate: Date(timeIntervalSince1970: modificationTimeInterval)) - #endif + guard let attributes = try? FileManager.default.attributesOfItem(atPath: path.pathString) else { return nil } + let size = Self.fileSize(from: attributes) + let modificationDate = (attributes[.modificationDate] as? Date) ?? Date() + return FileMetadata(size: size, lastModificationDate: modificationDate) } public func setFileTimes( @@ -829,21 +690,46 @@ public struct FileSystem: FileSysteming, Sendable { try FileManager.default.setAttributes(attributes, ofItemAtPath: path.pathString) } #else - let lastAccess = lastAccessDate.map { Self.dateToTimespec($0) } - let lastModification = lastModificationDate.map { Self.dateToTimespec($0) } - try await _NIOFileSystem.FileSystem.shared.withFileHandle( - forReadingAt: .init(path.pathString) - ) { handle in - try await handle.setTimes(lastAccess: lastAccess, lastDataModification: lastModification) - } + try Self.updateFileTimes( + path: path.pathString, + lastAccessDate: lastAccessDate, + lastModificationDate: lastModificationDate + ) #endif } #if !os(Windows) - private static func dateToTimespec(_ date: Date) -> _NIOFileSystem.FileInfo.Timespec { + private static func updateFileTimes( + path: String, + lastAccessDate: Date?, + lastModificationDate: Date? + ) throws { + var times = [ + timespec(tv_sec: 0, tv_nsec: Int(UTIME_OMIT)), + timespec(tv_sec: 0, tv_nsec: Int(UTIME_OMIT)), + ] + + if let lastAccessDate { + times[0] = dateToTimespec(lastAccessDate) + } + + if let lastModificationDate { + times[1] = dateToTimespec(lastModificationDate) + } + + let result = path.withCString { pathPointer in + utimensat(AT_FDCWD, pathPointer, ×, 0) + } + + guard result == 0 else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) + } + } + + private static func dateToTimespec(_ date: Date) -> timespec { let seconds = Int(date.timeIntervalSince1970) let nanoseconds = Int((date.timeIntervalSince1970 - Double(seconds)) * 1_000_000_000) - return _NIOFileSystem.FileInfo.Timespec(seconds: seconds, nanoseconds: nanoseconds) + return timespec(tv_sec: seconds, tv_nsec: nanoseconds) } #endif @@ -867,69 +753,49 @@ public struct FileSystem: FileSysteming, Sendable { private func createSymbolicLink(fromPathString: String, toPathString: String) async throws { logger?.debug("Creating symbolic link from \(fromPathString) to \(toPathString).") - #if os(Windows) - try FileManager.default.createSymbolicLink(atPath: fromPathString, withDestinationPath: toPathString) - #else - try await _NIOFileSystem.FileSystem.shared.createSymbolicLink( - at: FilePath(fromPathString), - withDestination: FilePath(toPathString) - ) - #endif + try await File.System.Link.Symbolic.create( + at: try File.Path(fromPathString), + pointingTo: try File.Path(toPathString) + ) } public func resolveSymbolicLink(_ symlinkPath: AbsolutePath) async throws -> AbsolutePath { logger?.debug("Resolving symbolink link at path \(symlinkPath.pathString).") - if !(try await exists(symlinkPath)) { + let filePath = try systemPath(symlinkPath) + if !(await File.System.Stat.exists(at: filePath)) { throw FileSystemError.absentSymbolicLink(symlinkPath) } - #if os(Windows) - let destination = try FileManager.default.destinationOfSymbolicLink(atPath: symlinkPath.pathString) - if destination.hasPrefix("/") || destination.contains(":") { - return try AbsolutePath(validating: destination) - } else { - return AbsolutePath(symlinkPath.parentDirectory, try RelativePath(validating: destination)) - } - #else - guard let info = try await _NIOFileSystem.FileSystem.shared.info( - forFileAt: FilePath(symlinkPath.pathString), - infoAboutSymbolicLink: true - ) - else { return symlinkPath } - switch info.type { - case .symlink: - break - default: - return symlinkPath - } - let path = try await _NIOFileSystem.FileSystem.shared.destinationOfSymbolicLink(at: FilePath(symlinkPath.pathString)) - if path.starts(with: "/") { - return try AbsolutePath(validating: path.string) + do { + let targetPath = try await File.System.Link.Read.Target.target(of: filePath) + if targetPath.isAbsolute { + return try absolutePath(targetPath) } else { - return AbsolutePath(symlinkPath.parentDirectory, try RelativePath(validating: path.string)) + return AbsolutePath( + symlinkPath.parentDirectory, + try RelativePath(validating: String(targetPath)) + ) } - #endif + } catch File.System.Link.Read.Target.Error.notASymlink { + return symlinkPath + } } #if !os(Windows) public func zipFileOrDirectoryContent(at path: Path.AbsolutePath, to: Path.AbsolutePath) async throws { logger?.debug("Zipping the file or contents of directory at path \(path.pathString) into \(to.pathString)") - try await NIOSingletons.posixBlockingThreadPool.runIfActive { - try FileManager.default.zipItem( - at: URL(fileURLWithPath: path.pathString), - to: URL(fileURLWithPath: to.pathString), - shouldKeepParent: false - ) - } + try FileManager.default.zipItem( + at: URL(fileURLWithPath: path.pathString), + to: URL(fileURLWithPath: to.pathString), + shouldKeepParent: false + ) } public func unzip(_ zipPath: Path.AbsolutePath, to: Path.AbsolutePath) async throws { logger?.debug("Unzipping the file at path \(zipPath.pathString) to \(to.pathString)") - try await NIOSingletons.posixBlockingThreadPool.runIfActive { - try FileManager.default.unzipItem( - at: URL(fileURLWithPath: zipPath.pathString), - to: URL(fileURLWithPath: to.pathString) - ) - } + try FileManager.default.unzipItem( + at: URL(fileURLWithPath: zipPath.pathString), + to: URL(fileURLWithPath: to.pathString) + ) } #endif @@ -981,15 +847,28 @@ extension AnyThrowingAsyncSequenceable where Element == Path.AbsolutePath { } } -#if !os(Windows) - extension FilePath { - fileprivate var path: AbsolutePath { - try! AbsolutePath(validating: string) // swiftlint:disable:this force_try +extension FileSystem { + private func writeFile(_ data: Data, to path: AbsolutePath) async throws { + try await File.System.Write.Atomic.write( + [UInt8](data), + to: try systemPath(path), + options: .init(createIntermediates: false) + ) + } + + private static func fileSize(from attributes: [FileAttributeKey: Any]) -> Int64 { + if let number = attributes[.size] as? NSNumber { + return number.int64Value } + if let size = attributes[.size] as? Int64 { + return size + } + if let size = attributes[.size] as? Int { + return Int64(size) + } + return 0 } -#endif -extension FileSystem { /// Creates and passes a temporary directory to the given action, coupling its lifecycle to the action's. /// - Parameter action: The action to run with the temporary directory. /// - Returns: Any value returned by the action. @@ -1010,4 +889,35 @@ extension FileSystem { public func writeAsJSON(_ item: some Encodable, at path: Path.AbsolutePath, options: Set) async throws { try await writeAsJSON(item, at: path, encoder: JSONEncoder(), options: options) } + + private func systemPath(_ path: AbsolutePath) throws -> File.Path { + try File.Path(path.pathString) + } + + private func absolutePath(_ path: File.Path) throws -> AbsolutePath { + try AbsolutePath(validating: String(path)) + } + + private func copyItem(from source: File.Path, to destination: File.Path) async throws { + guard !(await File.System.Stat.exists(at: destination)) else { + throw CocoaError(.fileWriteFileExists) + } + + let metadata = try File_System_Primitives.File.System.Stat.lstatInfo(at: source) + switch metadata.type { + case .directory: + try await File.System.Create.Directory.create(at: destination, options: .init(createIntermediates: true)) + for entry in try await File.Directory.Contents.list(at: File.Directory(source)) { + let sourceChild = try entry.path() + guard let lastComponent = sourceChild.lastComponent else { continue } + try await copyItem(from: sourceChild, to: File.Path(destination, appending: lastComponent)) + } + default: + try await File.System.Copy.copy( + from: source, + to: destination, + options: .init(overwrite: false, copyAttributes: true, followSymlinks: false) + ) + } + } } From 5704367da9534429a9a5b247b55a61f57e182206 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Pi=C3=B1era?= Date: Tue, 17 Mar 2026 11:28:24 +0100 Subject: [PATCH 02/19] refactor: replace SwiftNIO backend with system file APIs --- Package.resolved | 99 ---- Package.swift | 6 +- Sources/FileSystem/FileSystem.swift | 864 +++++++++++++++++++++++----- Sources/Glob/GlobSearch.swift | 140 ++++- 4 files changed, 839 insertions(+), 270 deletions(-) diff --git a/Package.resolved b/Package.resolved index 1b03059..2456403 100644 --- a/Package.resolved +++ b/Package.resolved @@ -9,42 +9,6 @@ "version" : "0.3.8" } }, - { - "identity" : "swift-async-algorithms-fork", - "kind" : "remoteSourceControl", - "location" : "https://github.com/coenttb/swift-async-algorithms-fork.git", - "state" : { - "revision" : "9352a14c5693451c0f76a433d22dbe86a92f61ae", - "version" : "1.2.0" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections.git", - "state" : { - "revision" : "8d9834a6189db730f6264db7556a7ffb751e99ee", - "version" : "1.4.0" - } - }, - { - "identity" : "swift-file-system", - "kind" : "remoteSourceControl", - "location" : "https://github.com/coenttb/swift-file-system", - "state" : { - "revision" : "4e65b651641a816e64f61664dbb357ad519fe05a", - "version" : "0.6.0" - } - }, - { - "identity" : "swift-incits-4-1986", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swift-standards/swift-incits-4-1986", - "state" : { - "revision" : "5e0ac8ce2e69663d690bc12993c9cebefffae613", - "version" : "0.7.1" - } - }, { "identity" : "swift-log", "kind" : "remoteSourceControl", @@ -54,69 +18,6 @@ "version" : "1.10.1" } }, - { - "identity" : "swift-memory-allocation", - "kind" : "remoteSourceControl", - "location" : "https://github.com/coenttb/swift-memory-allocation", - "state" : { - "revision" : "afe7e86f16981007b841470d5ea79f0026868bc3", - "version" : "0.2.0" - } - }, - { - "identity" : "swift-numerics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-numerics", - "state" : { - "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", - "version" : "1.1.1" - } - }, - { - "identity" : "swift-rfc-4648", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swift-standards/swift-rfc-4648", - "state" : { - "revision" : "029f384ec63890d98da9a09844bb2ef91a176872", - "version" : "0.6.0" - } - }, - { - "identity" : "swift-standards", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swift-standards/swift-standards", - "state" : { - "revision" : "948b7642f57f9aac26f4b6d1e3a86b5c1861ecbb", - "version" : "0.30.1" - } - }, - { - "identity" : "swift-syntax", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-syntax", - "state" : { - "revision" : "4799286537280063c85a32f09884cfbca301b1a1", - "version" : "602.0.0" - } - }, - { - "identity" : "swift-system", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-system", - "state" : { - "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", - "version" : "1.6.4" - } - }, - { - "identity" : "swift-testing-performance", - "kind" : "remoteSourceControl", - "location" : "https://github.com/coenttb/swift-testing-performance", - "state" : { - "revision" : "1a1967f7acfcb081e57122db463817c142cc7186", - "version" : "0.3.1" - } - }, { "identity" : "zipfoundation", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 3d9dd1b..ea95544 100644 --- a/Package.swift +++ b/Package.swift @@ -18,8 +18,8 @@ let package = Package( name: "FileSystem", platforms: [ - .macOS("26.0"), - .iOS("26.0"), + .macOS("13.0"), + .iOS("16.0"), ], products: [ .library( @@ -39,7 +39,6 @@ let package = Package( ), ], dependencies: [ - .package(url: "https://github.com/coenttb/swift-file-system", .upToNextMajor(from: "0.6.0")), .package(url: "https://github.com/tuist/Path", .upToNextMajor(from: "0.3.8")), .package(url: "https://github.com/apple/swift-log", .upToNextMajor(from: "1.10.1")), ] + zipFoundationDependency, @@ -48,7 +47,6 @@ let package = Package( name: "FileSystem", dependencies: [ "Glob", - .product(name: "File System", package: "swift-file-system"), .product(name: "Path", package: "Path"), .product(name: "Logging", package: "swift-log"), ] + zipFoundationTarget, diff --git a/Sources/FileSystem/FileSystem.swift b/Sources/FileSystem/FileSystem.swift index 0eb880e..38c4d63 100644 --- a/Sources/FileSystem/FileSystem.swift +++ b/Sources/FileSystem/FileSystem.swift @@ -1,18 +1,19 @@ import Foundation -import File_System -import File_System_Primitives import Glob import Logging import Path +#if os(Windows) + import WinSDK +#elseif canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#elseif canImport(Musl) + import Musl +#endif + #if !os(Windows) - #if canImport(Darwin) - import Darwin - #elseif canImport(Glibc) - import Glibc - #elseif canImport(Musl) - import Musl - #endif import ZIPFoundation #endif @@ -370,19 +371,16 @@ public struct FileSystem: FileSysteming, Sendable { } public func currentWorkingDirectory() async throws -> AbsolutePath { - return try AbsolutePath(validating: FileManager.default.currentDirectoryPath) + return try AbsolutePath(validating: try platformCurrentWorkingDirectoryPath()) } public func contentsOfDirectory(_ path: AbsolutePath) async throws -> [AbsolutePath] { - let directory = File.Directory(try systemPath(path)) - return try await File.Directory.Contents.list(at: directory).map { entry in - try absolutePath(entry.path()) - } + try platformDirectoryContents(at: path) } public func exists(_ path: AbsolutePath) async throws -> Bool { logger?.debug("Checking if a file or directory exists at path \(path.pathString).") - return await File.System.Stat.exists(at: try systemPath(path)) + return try platformItemExists(at: path) } public func exists(_ path: AbsolutePath, isDirectory: Bool) async throws -> Bool { @@ -391,52 +389,34 @@ public struct FileSystem: FileSysteming, Sendable { } else { logger?.debug("Checking if a file exists at path \(path.pathString).") } - let filePath = try systemPath(path) - guard await File.System.Stat.exists(at: filePath) else { return false } - let metadata = try await File.System.Stat.info(at: filePath) - let pathIsDirectory = metadata.type == .directory - return pathIsDirectory == isDirectory + return try platformItemExists(at: path, isDirectory: isDirectory) } public func touch(_ path: Path.AbsolutePath) async throws { logger?.debug("Touching a file at path \(path.pathString).") - if try await exists(path) { + + if try platformItemExists(at: path) { let now = Date() try await setFileTimes(of: path, lastAccessDate: now, lastModificationDate: now) return } - guard try await exists(path.parentDirectory, isDirectory: true) else { + guard try platformItemExists(at: path.parentDirectory, isDirectory: true) else { throw CocoaError(.fileNoSuchFile) } - try await writeFile(Data(), to: path) + try platformCreateEmptyFile(at: path) } public func remove(_ path: AbsolutePath) async throws { logger?.debug("Removing the file or directory at path: \(path.pathString).") - let filePath = try systemPath(path) - guard await File.System.Stat.exists(at: filePath) else { return } - try await File.System.Delete.delete(at: filePath, options: .init(recursive: true)) + guard try await exists(path) else { return } + try platformRemoveItem(at: path) } public func makeTemporaryDirectory(prefix: String) async throws -> AbsolutePath { - var systemTemporaryDirectory = NSTemporaryDirectory() - - /// The path to the directory /var is a symlink to /var/private. - /// NSTemporaryDirectory() returns the path to the symlink, so the logic here removes the symlink from it. - #if os(macOS) - if systemTemporaryDirectory.starts(with: "/var/") { - systemTemporaryDirectory = "/private\(systemTemporaryDirectory)" - } - #endif - let temporaryDirectory = try AbsolutePath(validating: systemTemporaryDirectory) - .appending(component: "\(prefix)-\(UUID().uuidString)") + let temporaryDirectory = try platformMakeTemporaryDirectory(prefix: prefix) logger?.debug("Creating a temporary directory at path \(temporaryDirectory.pathString).") - try await File.System.Create.Directory.create( - at: try systemPath(temporaryDirectory), - options: .init(createIntermediates: true) - ) return temporaryDirectory } @@ -458,11 +438,10 @@ public struct FileSystem: FileSysteming, Sendable { try? await makeDirectory(at: to.parentDirectory, options: [.createTargetParentDirectories]) } } - let sourcePath = try systemPath(from) - guard await File.System.Stat.exists(at: sourcePath) else { + guard try await exists(from) else { throw FileSystemError.moveNotFound(from: from, to: to) } - try await File.System.Move.move(from: sourcePath, to: try systemPath(to)) + try platformMoveItem(from: from, to: to) } public func makeDirectory(at: Path.AbsolutePath) async throws { @@ -482,10 +461,7 @@ public struct FileSystem: FileSysteming, Sendable { if !createIntermediates, !(try await exists(at.parentDirectory, isDirectory: true)) { throw FileSystemError.makeDirectoryAbsentParent(at) } - try await File.System.Create.Directory.create( - at: try systemPath(at), - options: .init(createIntermediates: createIntermediates) - ) + try platformCreateDirectory(at: at, createIntermediates: createIntermediates) } public func readFile(at path: Path.AbsolutePath) async throws -> Data { @@ -496,7 +472,7 @@ public struct FileSystem: FileSysteming, Sendable { if log { logger?.debug("Reading file at path \(path.pathString).") } - return Data(try await File.System.Read.Full.read(from: try systemPath(path))) + return try Data(contentsOf: URL(fileURLWithPath: path.pathString)) } public func readTextFile(at: Path.AbsolutePath) async throws -> String { @@ -534,7 +510,7 @@ public struct FileSystem: FileSysteming, Sendable { if options.contains(.overwrite), try await exists(path) { try await remove(path) } - try await writeFile(data, to: path) + try writeFile(data, to: path) } public func readPlistFile(at path: Path.AbsolutePath) async throws -> T where T: Decodable { @@ -568,7 +544,7 @@ public struct FileSystem: FileSysteming, Sendable { } let plistData = try encoder.encode(item) - try await writeFile(plistData, to: path) + try writeFile(plistData, to: path) } public func readJSONFile(at path: Path.AbsolutePath) async throws -> T where T: Decodable { @@ -601,36 +577,32 @@ public struct FileSystem: FileSysteming, Sendable { if options.contains(.overwrite), try await exists(path) { try await remove(path) } - try await writeFile(json, to: path) + try writeFile(json, to: path) } public func replace(_ to: AbsolutePath, with path: AbsolutePath) async throws { logger?.debug("Replacing file or directory at path \(path.pathString) with item at path \(to.pathString).") - let sourcePath = try systemPath(path) - let destinationPath = try systemPath(to) - if !(await File.System.Stat.exists(at: sourcePath)) { + if !(try await exists(path)) { throw FileSystemError.replacingItemAbsent(replacingPath: path, replacedPath: to) } if !(try await exists(to.parentDirectory)) { try await makeDirectory(at: to.parentDirectory) } - if await File.System.Stat.exists(at: destinationPath) { - try await File.System.Delete.delete(at: destinationPath, options: .init(recursive: true)) + if try platformItemExists(at: to, followSymlinks: false) { + try platformRemoveItem(at: to) } - try await copyItem(from: sourcePath, to: destinationPath) + try platformCopyItem(from: path, to: to) } public func copy(_ from: AbsolutePath, to: AbsolutePath) async throws { logger?.debug("Copying file or directory at path \(from.pathString) to \(to.pathString).") - let sourcePath = try systemPath(from) - let destinationPath = try systemPath(to) - if !(await File.System.Stat.exists(at: sourcePath)) { + if !(try await exists(from)) { throw FileSystemError.copiedItemAbsent(copiedPath: from, intoPath: to) } if !(try await exists(to.parentDirectory)) { try await makeDirectory(at: to.parentDirectory) } - try await copyItem(from: sourcePath, to: destinationPath) + try platformCopyItem(from: from, to: to) } public func runInTemporaryDirectory( @@ -662,16 +634,12 @@ public struct FileSystem: FileSysteming, Sendable { ) public func fileSizeInBytes(at path: AbsolutePath) async throws -> Int64? { logger?.debug("Getting the size in bytes of file at path \(path.pathString).") - guard let attributes = try? FileManager.default.attributesOfItem(atPath: path.pathString) else { return nil } - return Self.fileSize(from: attributes) + return try await fileMetadata(at: path)?.size } public func fileMetadata(at path: AbsolutePath) async throws -> FileMetadata? { logger?.debug("Getting the metadata of file at path \(path.pathString).") - guard let attributes = try? FileManager.default.attributesOfItem(atPath: path.pathString) else { return nil } - let size = Self.fileSize(from: attributes) - let modificationDate = (attributes[.modificationDate] as? Date) ?? Date() - return FileMetadata(size: size, lastModificationDate: modificationDate) + return try platformFileMetadata(at: path) } public func setFileTimes( @@ -680,22 +648,11 @@ public struct FileSystem: FileSysteming, Sendable { lastModificationDate: Date? ) async throws { logger?.debug("Setting file times at path \(path.pathString).") - - #if os(Windows) - var attributes: [FileAttributeKey: Any] = [:] - if let lastModificationDate { - attributes[.modificationDate] = lastModificationDate - } - if !attributes.isEmpty { - try FileManager.default.setAttributes(attributes, ofItemAtPath: path.pathString) - } - #else - try Self.updateFileTimes( - path: path.pathString, - lastAccessDate: lastAccessDate, - lastModificationDate: lastModificationDate - ) - #endif + try platformSetFileTimes( + of: path, + lastAccessDate: lastAccessDate, + lastModificationDate: lastModificationDate + ) } #if !os(Windows) @@ -753,37 +710,38 @@ public struct FileSystem: FileSysteming, Sendable { private func createSymbolicLink(fromPathString: String, toPathString: String) async throws { logger?.debug("Creating symbolic link from \(fromPathString) to \(toPathString).") - try await File.System.Link.Symbolic.create( - at: try File.Path(fromPathString), - pointingTo: try File.Path(toPathString) - ) + try platformCreateSymbolicLink(fromPathString: fromPathString, toPathString: toPathString) } public func resolveSymbolicLink(_ symlinkPath: AbsolutePath) async throws -> AbsolutePath { logger?.debug("Resolving symbolink link at path \(symlinkPath.pathString).") - let filePath = try systemPath(symlinkPath) - if !(await File.System.Stat.exists(at: filePath)) { + if !(try await exists(symlinkPath)) { throw FileSystemError.absentSymbolicLink(symlinkPath) } + let destination: String do { - let targetPath = try await File.System.Link.Read.Target.target(of: filePath) - if targetPath.isAbsolute { - return try absolutePath(targetPath) - } else { - return AbsolutePath( - symlinkPath.parentDirectory, - try RelativePath(validating: String(targetPath)) - ) - } - } catch File.System.Link.Read.Target.Error.notASymlink { + destination = try platformReadSymbolicLink(at: symlinkPath) + } catch { return symlinkPath } + + #if os(Windows) + if destination.hasPrefix("/") || destination.contains(":") { + return try AbsolutePath(validating: destination) + } + #else + if destination.hasPrefix("/") { + return try AbsolutePath(validating: destination) + } + #endif + + return AbsolutePath(symlinkPath.parentDirectory, try RelativePath(validating: destination)) } #if !os(Windows) public func zipFileOrDirectoryContent(at path: Path.AbsolutePath, to: Path.AbsolutePath) async throws { logger?.debug("Zipping the file or contents of directory at path \(path.pathString) into \(to.pathString)") - try FileManager.default.zipItem( + try createArchive( at: URL(fileURLWithPath: path.pathString), to: URL(fileURLWithPath: to.pathString), shouldKeepParent: false @@ -792,7 +750,7 @@ public struct FileSystem: FileSysteming, Sendable { public func unzip(_ zipPath: Path.AbsolutePath, to: Path.AbsolutePath) async throws { logger?.debug("Unzipping the file at path \(zipPath.pathString) to \(to.pathString)") - try FileManager.default.unzipItem( + try extractArchive( at: URL(fileURLWithPath: zipPath.pathString), to: URL(fileURLWithPath: to.pathString) ) @@ -848,25 +806,8 @@ extension AnyThrowingAsyncSequenceable where Element == Path.AbsolutePath { } extension FileSystem { - private func writeFile(_ data: Data, to path: AbsolutePath) async throws { - try await File.System.Write.Atomic.write( - [UInt8](data), - to: try systemPath(path), - options: .init(createIntermediates: false) - ) - } - - private static func fileSize(from attributes: [FileAttributeKey: Any]) -> Int64 { - if let number = attributes[.size] as? NSNumber { - return number.int64Value - } - if let size = attributes[.size] as? Int64 { - return size - } - if let size = attributes[.size] as? Int { - return Int64(size) - } - return 0 + private func writeFile(_ data: Data, to path: AbsolutePath) throws { + try data.write(to: URL(fileURLWithPath: path.pathString)) } /// Creates and passes a temporary directory to the given action, coupling its lifecycle to the action's. @@ -890,34 +831,675 @@ extension FileSystem { try await writeAsJSON(item, at: path, encoder: JSONEncoder(), options: options) } - private func systemPath(_ path: AbsolutePath) throws -> File.Path { - try File.Path(path.pathString) + #if !os(Windows) + private func createArchive(at sourceURL: URL, to destinationURL: URL, shouldKeepParent: Bool) throws { + let sourcePath = try AbsolutePath(validating: sourceURL.path) + let destinationPath = try AbsolutePath(validating: destinationURL.path) + guard try platformItemExists(at: sourcePath) else { + throw CocoaError(.fileReadNoSuchFile, userInfo: [NSFilePathErrorKey: sourceURL.path]) + } + guard try !platformItemExists(at: destinationPath, followSymlinks: false) else { + throw CocoaError(.fileWriteFileExists, userInfo: [NSFilePathErrorKey: destinationURL.path]) + } + + let archive = try Archive(url: destinationURL, accessMode: .create) + if try platformItemExists(at: sourcePath, isDirectory: true) { + let baseURL = shouldKeepParent + ? URL(fileURLWithPath: sourcePath.parentDirectory.pathString) + : sourceURL + let prefix = shouldKeepParent ? "\(sourcePath.basename)/" : "" + for entryPath in try descendantRelativePaths(of: sourcePath) { + try archive.addEntry( + with: "\(prefix)\(entryPath)", + relativeTo: baseURL, + compressionMethod: .none + ) + } + } else { + try archive.addEntry( + with: sourceURL.lastPathComponent, + relativeTo: sourceURL.deletingLastPathComponent(), + compressionMethod: .none + ) + } + } + + private func extractArchive(at sourceURL: URL, to destinationURL: URL) throws { + let sourcePath = try AbsolutePath(validating: sourceURL.path) + guard try platformItemExists(at: sourcePath) else { + throw CocoaError(.fileReadNoSuchFile, userInfo: [NSFilePathErrorKey: sourceURL.path]) + } + + let archive = try Archive(url: sourceURL, accessMode: .read) + for entry in archive { + let entryURL = destinationURL.appendingPathComponent(entry.path) + let checksum = try archive.extract(entry, to: entryURL) + if checksum != entry.checksum { + throw Archive.ArchiveError.invalidCRC32 + } + } + } + #endif +} + +private enum PlatformFileKind { + case directory + case file + case symbolicLink + case other +} + +private struct PlatformFileInfo { + let kind: PlatformFileKind + let size: Int64 + let modificationDate: Date +} + +extension FileSystem { + private func platformCurrentWorkingDirectoryPath() throws -> String { + #if os(Windows) + var buffer = [WCHAR](repeating: 0, count: Int(MAX_PATH) + 1) + var length = buffer.withUnsafeMutableBufferPointer { + GetCurrentDirectoryW(DWORD($0.count), $0.baseAddress) + } + guard length > 0 else { throw windowsError() } + if Int(length) >= buffer.count { + buffer = [WCHAR](repeating: 0, count: Int(length) + 1) + length = buffer.withUnsafeMutableBufferPointer { + GetCurrentDirectoryW(DWORD($0.count), $0.baseAddress) + } + guard length > 0, Int(length) < buffer.count else { throw windowsError() } + } + return buffer.withUnsafeBufferPointer { + String(decodingCString: $0.baseAddress!, as: UTF16.self) + } + #else + var buffer = [CChar](repeating: 0, count: Int(PATH_MAX)) + guard getcwd(&buffer, buffer.count) != nil else { throw posixError() } + return String(cString: buffer) + #endif + } + + private func platformDirectoryContents(at path: AbsolutePath) throws -> [AbsolutePath] { + #if os(Windows) + guard try platformItemExists(at: path, isDirectory: true) else { + throw windowsError(DWORD(ERROR_PATH_NOT_FOUND)) + } + + var entries: [AbsolutePath] = [] + var findData = WIN32_FIND_DATAW() + let searchPath = "\(windowsPathString(path.pathString))\\*" + let handle = searchPath.withCString(encodedAs: UTF16.self) { wpath in + FindFirstFileW(wpath, &findData) + } + guard handle != INVALID_HANDLE_VALUE else { throw windowsError() } + defer { FindClose(handle) } + + repeat { + let name = windowsDirectoryEntryName(from: &findData) + guard name != ".", name != ".." else { continue } + entries.append(path.appending(component: name)) + } while FindNextFileW(handle, &findData) != 0 + + let lastError = GetLastError() + if lastError != DWORD(ERROR_NO_MORE_FILES) { + throw windowsError(lastError) + } + + return entries + #else + guard let directory = opendir(path.pathString) else { throw posixError() } + defer { closedir(directory) } + + var entries: [AbsolutePath] = [] + errno = 0 + while let entryPointer = readdir(directory) { + let entry = entryPointer.pointee + var entryName = entry.d_name + let capacity = MemoryLayout.size(ofValue: entryName) / MemoryLayout.size + let name = withUnsafePointer(to: &entryName) { pointer in + pointer.withMemoryRebound( + to: CChar.self, + capacity: capacity + ) { + String(cString: $0) + } + } + guard name != ".", name != ".." else { continue } + entries.append(path.appending(component: name)) + } + + if errno != 0 { + throw posixError() + } + + return entries + #endif + } + + private func platformItemExists(at path: AbsolutePath) throws -> Bool { + try platformItemExists(at: path, followSymlinks: true) + } + + private func platformItemExists(at path: AbsolutePath, isDirectory: Bool) throws -> Bool { + guard let info = try platformFileInfo(at: path, followSymlinks: true) else { return false } + return info.kind == (isDirectory ? .directory : .file) + } + + private func platformItemExists(at path: AbsolutePath, followSymlinks: Bool) throws -> Bool { + try platformFileInfo(at: path, followSymlinks: followSymlinks) != nil + } + + private func platformCreateEmptyFile(at path: AbsolutePath) throws { + #if os(Windows) + let handle = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { wpath in + CreateFileW( + wpath, + DWORD(GENERIC_WRITE), + DWORD(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE), + nil, + DWORD(CREATE_NEW), + DWORD(FILE_ATTRIBUTE_NORMAL), + nil + ) + } + guard let handle, handle != INVALID_HANDLE_VALUE else { throw windowsError() } + CloseHandle(handle) + #else + let descriptor = path.pathString.withCString { pathPointer in + open(pathPointer, O_WRONLY | O_CREAT | O_EXCL, mode_t(0o666)) + } + guard descriptor >= 0 else { throw posixError() } + _ = close(descriptor) + #endif + } + + private func platformRemoveItem(at path: AbsolutePath) throws { + #if os(Windows) + let attributes = windowsAttributes(atPath: path.pathString) + guard attributes != INVALID_FILE_ATTRIBUTES else { return } + + let isDirectory = (attributes & DWORD(FILE_ATTRIBUTE_DIRECTORY)) != 0 + let isReparsePoint = (attributes & DWORD(FILE_ATTRIBUTE_REPARSE_POINT)) != 0 + if isDirectory, !isReparsePoint { + for child in try platformDirectoryContents(at: path) { + try platformRemoveItem(at: child) + } + let success = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { + RemoveDirectoryW($0) + } + guard success != 0 else { throw windowsError() } + } else if isDirectory { + let success = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { + RemoveDirectoryW($0) + } + guard success != 0 else { throw windowsError() } + } else { + let success = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { + DeleteFileW($0) + } + guard success != 0 else { throw windowsError() } + } + #else + guard let info = try platformFileInfo(at: path, followSymlinks: false) else { return } + switch info.kind { + case .directory: + for child in try platformDirectoryContents(at: path) { + try platformRemoveItem(at: child) + } + let result = path.pathString.withCString { rmdir($0) } + guard result == 0 else { throw posixError() } + case .file, .symbolicLink, .other: + let result = path.pathString.withCString { unlink($0) } + guard result == 0 else { throw posixError() } + } + #endif + } + + private func platformMakeTemporaryDirectory(prefix: String) throws -> AbsolutePath { + #if os(Windows) + var buffer = [WCHAR](repeating: 0, count: Int(MAX_PATH) + 1) + var length = buffer.withUnsafeMutableBufferPointer { + GetTempPathW(DWORD($0.count), $0.baseAddress) + } + guard length > 0 else { throw windowsError() } + if Int(length) >= buffer.count { + buffer = [WCHAR](repeating: 0, count: Int(length) + 1) + length = buffer.withUnsafeMutableBufferPointer { + GetTempPathW(DWORD($0.count), $0.baseAddress) + } + guard length > 0, Int(length) < buffer.count else { throw windowsError() } + } + let temporaryDirectory = try AbsolutePath( + validating: buffer.withUnsafeBufferPointer { + String(decodingCString: $0.baseAddress!, as: UTF16.self) + } + ) + let path = temporaryDirectory.appending(component: "\(prefix)-\(UUID().uuidString)") + try platformCreateDirectory(at: path, createIntermediates: true) + return path + #else + var systemTemporaryDirectory = NSTemporaryDirectory() + + #if os(macOS) + if systemTemporaryDirectory.starts(with: "/var/") { + systemTemporaryDirectory = "/private\(systemTemporaryDirectory)" + } + #endif + + let basePath = try AbsolutePath(validating: systemTemporaryDirectory) + var template = basePath.appending(component: "\(prefix)-XXXXXX").pathString.utf8CString + let createdPath = template.withUnsafeMutableBufferPointer { pointer -> String? in + guard let pathPointer = mkdtemp(pointer.baseAddress) else { return nil } + return String(cString: pathPointer) + } + guard let createdPath else { throw posixError() } + return try AbsolutePath(validating: createdPath) + #endif + } + + private func platformMoveItem(from: AbsolutePath, to: AbsolutePath) throws { + if try platformItemExists(at: to, followSymlinks: false) { + throw fileExistsError(at: to) + } + + #if os(Windows) + let success = windowsPathString(from.pathString).withCString(encodedAs: UTF16.self) { wsrc in + windowsPathString(to.pathString).withCString(encodedAs: UTF16.self) { wdst in + MoveFileExW(wsrc, wdst, DWORD(MOVEFILE_COPY_ALLOWED)) + } + } + guard success != 0 else { throw windowsError() } + #else + let result = from.pathString.withCString { sourcePointer in + to.pathString.withCString { destinationPointer in + rename(sourcePointer, destinationPointer) + } + } + guard result == 0 else { + let error = errno + if error == EXDEV { + try platformCopyItem(from: from, to: to) + try platformRemoveItem(at: from) + return + } + throw posixError(error) + } + #endif } - private func absolutePath(_ path: File.Path) throws -> AbsolutePath { - try AbsolutePath(validating: String(path)) + private func platformCreateDirectory(at path: AbsolutePath, createIntermediates: Bool) throws { + #if os(Windows) + if let existing = try platformFileInfo(at: path, followSymlinks: false) { + guard existing.kind == .directory else { throw windowsError(DWORD(ERROR_ALREADY_EXISTS)) } + return + } + if createIntermediates { + let parent = path.parentDirectory + if parent != path { + try platformCreateDirectory(at: parent, createIntermediates: true) + } + } + let success = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { wpath in + CreateDirectoryW(wpath, nil) + } + guard success != 0 else { + let error = GetLastError() + if error == DWORD(ERROR_ALREADY_EXISTS), try platformItemExists(at: path, isDirectory: true) { + return + } + throw windowsError(error) + } + #else + if let existing = try platformFileInfo(at: path, followSymlinks: false) { + guard existing.kind == .directory else { throw posixError(EEXIST) } + return + } + if createIntermediates { + let parent = path.parentDirectory + if parent != path { + try platformCreateDirectory(at: parent, createIntermediates: true) + } + } + let result = path.pathString.withCString { mkdir($0, mode_t(0o755)) } + guard result == 0 else { + let error = errno + if error == EEXIST, try platformItemExists(at: path, isDirectory: true) { + return + } + throw posixError(error) + } + #endif } - private func copyItem(from source: File.Path, to destination: File.Path) async throws { - guard !(await File.System.Stat.exists(at: destination)) else { - throw CocoaError(.fileWriteFileExists) + private func platformCopyItem(from: AbsolutePath, to: AbsolutePath) throws { + if try platformItemExists(at: to, followSymlinks: false) { + throw fileExistsError(at: to) + } + + guard let info = try platformFileInfo(at: from, followSymlinks: false) else { + throw CocoaError(.fileReadNoSuchFile, userInfo: [NSFilePathErrorKey: from.pathString]) } - let metadata = try File_System_Primitives.File.System.Stat.lstatInfo(at: source) - switch metadata.type { + switch info.kind { case .directory: - try await File.System.Create.Directory.create(at: destination, options: .init(createIntermediates: true)) - for entry in try await File.Directory.Contents.list(at: File.Directory(source)) { - let sourceChild = try entry.path() - guard let lastComponent = sourceChild.lastComponent else { continue } - try await copyItem(from: sourceChild, to: File.Path(destination, appending: lastComponent)) - } - default: - try await File.System.Copy.copy( - from: source, - to: destination, - options: .init(overwrite: false, copyAttributes: true, followSymlinks: false) + try platformCreateDirectory(at: to, createIntermediates: false) + for child in try platformDirectoryContents(at: from) { + try platformCopyItem(from: child, to: to.appending(component: child.basename)) + } + case .symbolicLink: + let destination = try platformReadSymbolicLink(at: from) + try platformCreateSymbolicLink(fromPathString: to.pathString, toPathString: destination) + case .file, .other: + #if os(Windows) + let success = windowsPathString(from.pathString).withCString(encodedAs: UTF16.self) { wsrc in + windowsPathString(to.pathString).withCString(encodedAs: UTF16.self) { wdst in + CopyFileW(wsrc, wdst, true) + } + } + guard success != 0 else { throw windowsError() } + #else + try platformCopyRegularFile(from: from, to: to) + #endif + } + } + + private func platformFileMetadata(at path: AbsolutePath) throws -> FileMetadata? { + guard let info = try platformFileInfo(at: path, followSymlinks: true) else { return nil } + return FileMetadata(size: info.size, lastModificationDate: info.modificationDate) + } + + private func platformSetFileTimes( + of path: AbsolutePath, + lastAccessDate: Date?, + lastModificationDate: Date? + ) throws { + #if os(Windows) + let handle = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { wpath in + CreateFileW( + wpath, + DWORD(FILE_WRITE_ATTRIBUTES), + DWORD(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE), + nil, + DWORD(OPEN_EXISTING), + DWORD(FILE_ATTRIBUTE_NORMAL), + nil + ) + } + guard let handle, handle != INVALID_HANDLE_VALUE else { throw windowsError() } + defer { CloseHandle(handle) } + + var accessTime = lastAccessDate.map(windowsFileTime(from:)) + var modificationTime = lastModificationDate.map(windowsFileTime(from:)) + let success = SetFileTime(handle, nil, &accessTime, &modificationTime) + guard success != 0 else { throw windowsError() } + #else + try Self.updateFileTimes( + path: path.pathString, + lastAccessDate: lastAccessDate, + lastModificationDate: lastModificationDate ) + #endif + } + + private func platformCreateSymbolicLink(fromPathString: String, toPathString: String) throws { + #if os(Windows) + var flags = DWORD(0x2) + let targetAttributes = windowsAttributes(atPath: toPathString) + if targetAttributes != INVALID_FILE_ATTRIBUTES, + (targetAttributes & DWORD(FILE_ATTRIBUTE_DIRECTORY)) != 0 + { + flags |= DWORD(SYMBOLIC_LINK_FLAG_DIRECTORY) + } + let success = windowsPathString(fromPathString).withCString(encodedAs: UTF16.self) { wlink in + windowsPathString(toPathString).withCString(encodedAs: UTF16.self) { wtarget in + CreateSymbolicLinkW(wlink, wtarget, flags) + } + } + guard success != 0 else { throw windowsError() } + #else + let result = fromPathString.withCString { linkPointer in + toPathString.withCString { targetPointer in + symlink(targetPointer, linkPointer) + } + } + guard result == 0 else { throw posixError() } + #endif + } + + private func platformReadSymbolicLink(at path: AbsolutePath) throws -> String { + #if os(Windows) + let handle = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { wpath in + CreateFileW( + wpath, + 0, + DWORD(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE), + nil, + DWORD(OPEN_EXISTING), + DWORD(FILE_FLAG_BACKUP_SEMANTICS), + nil + ) + } + guard let handle, handle != INVALID_HANDLE_VALUE else { throw windowsError() } + defer { CloseHandle(handle) } + + var buffer = [WCHAR](repeating: 0, count: Int(MAX_PATH) + 1) + var length = buffer.withUnsafeMutableBufferPointer { + GetFinalPathNameByHandleW(handle, $0.baseAddress, DWORD($0.count), DWORD(FILE_NAME_NORMALIZED)) + } + guard length > 0 else { throw windowsError() } + if Int(length) >= buffer.count { + buffer = [WCHAR](repeating: 0, count: Int(length) + 1) + length = buffer.withUnsafeMutableBufferPointer { + GetFinalPathNameByHandleW(handle, $0.baseAddress, DWORD($0.count), DWORD(FILE_NAME_NORMALIZED)) + } + guard length > 0, Int(length) < buffer.count else { throw windowsError() } + } + + var resolvedPath = buffer.withUnsafeBufferPointer { + String(decodingCString: $0.baseAddress!, as: UTF16.self) + } + if resolvedPath.hasPrefix("\\\\?\\UNC\\") { + resolvedPath = "\\\(resolvedPath.dropFirst(7))" + } else if resolvedPath.hasPrefix("\\\\?\\") { + resolvedPath.removeFirst(4) + } + return resolvedPath + #else + var buffer = [CChar](repeating: 0, count: Int(PATH_MAX) + 1) + let length = path.pathString.withCString { readlink($0, &buffer, buffer.count - 1) } + guard length >= 0 else { throw posixError() } + buffer[Int(length)] = 0 + return String(cString: buffer) + #endif + } + + private func descendantRelativePaths(of root: AbsolutePath) throws -> [String] { + try descendantRelativePaths(of: root, prefix: "") + } + + private func descendantRelativePaths(of directory: AbsolutePath, prefix: String) throws -> [String] { + var descendants: [String] = [] + for child in try platformDirectoryContents(at: directory) { + let relativePath = prefix.isEmpty ? child.basename : "\(prefix)/\(child.basename)" + descendants.append(relativePath) + + if try platformFileInfo(at: child, followSymlinks: false)?.kind == .directory { + descendants.append(contentsOf: try descendantRelativePaths(of: child, prefix: relativePath)) + } } + return descendants + } + + private func platformFileInfo(at path: AbsolutePath, followSymlinks: Bool) throws -> PlatformFileInfo? { + #if os(Windows) + var findData = WIN32_FIND_DATAW() + let handle = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { wpath in + FindFirstFileW(wpath, &findData) + } + guard handle != INVALID_HANDLE_VALUE else { + let error = GetLastError() + if error == DWORD(ERROR_FILE_NOT_FOUND) || error == DWORD(ERROR_PATH_NOT_FOUND) { + return nil + } + throw windowsError(error) + } + defer { FindClose(handle) } + + let attributes = findData.dwFileAttributes + let kind: PlatformFileKind + if (attributes & DWORD(FILE_ATTRIBUTE_DIRECTORY)) != 0 { + kind = .directory + } else if (attributes & DWORD(FILE_ATTRIBUTE_REPARSE_POINT)) != 0 { + kind = .symbolicLink + } else { + kind = .file + } + let size = (Int64(findData.nFileSizeHigh) << 32) | Int64(findData.nFileSizeLow) + return PlatformFileInfo( + kind: kind, + size: size, + modificationDate: windowsDate(from: findData.ftLastWriteTime) + ) + #else + var info = stat() + let result = path.pathString.withCString { pathPointer in + if followSymlinks { + stat(pathPointer, &info) + } else { + lstat(pathPointer, &info) + } + } + guard result == 0 else { + let error = errno + if error == ENOENT || error == ENOTDIR { + return nil + } + throw posixError(error) + } + + return PlatformFileInfo( + kind: posixFileKind(from: info), + size: Int64(info.st_size), + modificationDate: posixModificationDate(from: info) + ) + #endif } + + #if !os(Windows) + private func platformCopyRegularFile(from: AbsolutePath, to: AbsolutePath) throws { + let sourceDescriptor = from.pathString.withCString { open($0, O_RDONLY) } + guard sourceDescriptor >= 0 else { throw posixError() } + defer { _ = close(sourceDescriptor) } + + let destinationDescriptor = to.pathString.withCString { + open($0, O_WRONLY | O_CREAT | O_EXCL | O_TRUNC, mode_t(0o666)) + } + guard destinationDescriptor >= 0 else { throw posixError() } + defer { _ = close(destinationDescriptor) } + + var buffer = [UInt8](repeating: 0, count: 64 * 1024) + while true { + let readCount = buffer.withUnsafeMutableBytes { + read(sourceDescriptor, $0.baseAddress, $0.count) + } + guard readCount >= 0 else { throw posixError() } + guard readCount > 0 else { return } + + var written = 0 + while written < readCount { + let writeCount = buffer.withUnsafeBytes { rawBuffer -> Int in + let baseAddress = rawBuffer.baseAddress!.advanced(by: written) + return write(destinationDescriptor, baseAddress, readCount - written) + } + guard writeCount >= 0 else { throw posixError() } + written += writeCount + } + } + } + + private func posixFileKind(from info: stat) -> PlatformFileKind { + switch info.st_mode & S_IFMT { + case S_IFDIR: + return .directory + case S_IFLNK: + return .symbolicLink + case S_IFREG: + return .file + default: + return .other + } + } + + private func posixModificationDate(from info: stat) -> Date { + #if canImport(Darwin) + let seconds = TimeInterval(info.st_mtimespec.tv_sec) + let nanoseconds = TimeInterval(info.st_mtimespec.tv_nsec) / 1_000_000_000 + #else + let seconds = TimeInterval(info.st_mtim.tv_sec) + let nanoseconds = TimeInterval(info.st_mtim.tv_nsec) / 1_000_000_000 + #endif + return Date(timeIntervalSince1970: seconds + nanoseconds) + } + + private func posixError(_ code: Int32 = errno) -> NSError { + NSError(domain: NSPOSIXErrorDomain, code: Int(code)) + } + #else + private func windowsAttributes(atPath path: String) -> DWORD { + windowsPathString(path).withCString(encodedAs: UTF16.self) { + GetFileAttributesW($0) + } + } + + private func windowsPathString(_ path: String) -> String { + path.replacingOccurrences(of: "/", with: "\\") + } + + private func windowsDirectoryEntryName(from findData: inout WIN32_FIND_DATAW) -> String { + withUnsafePointer(to: &findData.cFileName) { pointer in + pointer.withMemoryRebound( + to: WCHAR.self, + capacity: MemoryLayout.size(ofValue: findData.cFileName) / MemoryLayout.size + ) { + String(decodingCString: $0, as: UTF16.self) + } + } + } + + private func windowsDate(from fileTime: FILETIME) -> Date { + let intervals = (Int64(fileTime.dwHighDateTime) << 32) | Int64(fileTime.dwLowDateTime) + let unixIntervals = intervals - 116_444_736_000_000_000 + let seconds = TimeInterval(unixIntervals / 10_000_000) + let nanoseconds = TimeInterval(unixIntervals % 10_000_000) / 10_000_000 + return Date(timeIntervalSince1970: seconds + nanoseconds) + } + + private func windowsFileTime(from date: Date) -> FILETIME { + let timeInterval = date.timeIntervalSince1970 + let wholeSeconds = Int64(timeInterval) + let remainder = timeInterval - TimeInterval(wholeSeconds) + let intervals = 116_444_736_000_000_000 + + (wholeSeconds * 10_000_000) + + Int64(remainder * 10_000_000) + return FILETIME( + dwLowDateTime: DWORD(intervals & 0xFFFF_FFFF), + dwHighDateTime: DWORD((intervals >> 32) & 0xFFFF_FFFF) + ) + } + + private func windowsError(_ code: DWORD = GetLastError()) -> NSError { + NSError(domain: "WinSDK", code: Int(code)) + } + + private func fileExistsError(at path: AbsolutePath) -> CocoaError { + CocoaError(.fileWriteFileExists, userInfo: [NSFilePathErrorKey: path.pathString]) + } + #endif + + #if !os(Windows) + private func fileExistsError(at path: AbsolutePath) -> CocoaError { + CocoaError(.fileWriteFileExists, userInfo: [NSFilePathErrorKey: path.pathString]) + } + #endif } diff --git a/Sources/Glob/GlobSearch.swift b/Sources/Glob/GlobSearch.swift index 1681c73..17a7cf2 100644 --- a/Sources/Glob/GlobSearch.swift +++ b/Sources/Glob/GlobSearch.swift @@ -1,5 +1,15 @@ import Foundation +#if os(Windows) + import WinSDK +#elseif canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#elseif canImport(Musl) + import Musl +#endif + /// The result of a custom matcher for searching directory components public struct MatchResult { /// When true, the url will be added to the output @@ -32,7 +42,7 @@ public struct MatchResult { // swiftlint:disable:next function_body_length public func search( // swiftformat:disable unusedArguments - directory baseURL: URL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath), + directory baseURL: URL = URL.with(filePath: ProcessInfo.processInfo.environment["PWD"] ?? "."), include: [Pattern] = [], exclude: [Pattern] = [], includingPropertiesForKeys keys: [URLResourceKey] = [], @@ -74,27 +84,20 @@ public func search( } if include.sections.isEmpty { - if FileManager.default - .fileExists(atPath: baseURL.absoluteString.removingPercentEncoding ?? baseURL.absoluteString) - { + if (try? normalizedFileURL(baseURL).checkResourceIsReachable()) == true { continuation.yield(baseURL) } continue } - let path = baseURL.absoluteString.removingPercentEncoding ?? baseURL.absoluteString - let symbolicLinkDestination = URL.with(filePath: path).resolvingSymlinksInPath() - var isDirectory: ObjCBool = false + let symbolicLinkDestination = normalizedFileURL(baseURL).resolvingSymlinksInPath() - let symbolicLinkDestinationPath: String = symbolicLinkDestination - .path() - .removingPercentEncoding ?? symbolicLinkDestination.path() + let symbolicLinkDestinationPath = decodedPath(symbolicLinkDestination) - guard FileManager.default.fileExists( - atPath: symbolicLinkDestinationPath, - isDirectory: &isDirectory - ), - isDirectory.boolValue + guard + let resourceValues = try? URL.with(filePath: symbolicLinkDestinationPath) + .resourceValues(forKeys: [.isDirectoryKey]), + resourceValues.isDirectory == true else { continue } try await search( @@ -164,16 +167,10 @@ private func search( relativePath relativeDirectoryPath: String, continuation: AsyncThrowingStream.Continuation ) async throws { - var options: FileManager.DirectoryEnumerationOptions = [ - .producesRelativePathURLs, - ] - if skipHiddenFiles { - options.insert(.skipsHiddenFiles) - } - let contents = try FileManager.default.contentsOfDirectory( + let contents = try directoryContents( at: symbolicLinkDestination ?? directory, - includingPropertiesForKeys: keys + [.isDirectoryKey], - options: options + includingPropertiesForKeys: keys + [.isDirectoryKey, .isSymbolicLinkKey], + skipHiddenFiles: skipHiddenFiles ) try await withThrowingTaskGroup(of: Void.self) { group in @@ -229,8 +226,8 @@ private func search( extension URL { fileprivate func isAncestorOf(_ maybeChild: URL) -> Bool { - let maybeChildFileURL = maybeChild.isFileURL ? maybeChild : .with(filePath: maybeChild.path) - let maybeAncestorFileURL = isFileURL ? self : .with(filePath: path) + let maybeChildFileURL = maybeChild.isFileURL ? maybeChild : .with(filePath: decodedPath(maybeChild)) + let maybeAncestorFileURL = isFileURL ? self : .with(filePath: decodedPath(self)) do { let maybeChildResourceValues = try maybeChildFileURL.standardizedFileURL.resolvingSymlinksInPath() @@ -250,6 +247,97 @@ extension URL { } } +private func directoryContents( + at directory: URL, + includingPropertiesForKeys keys: [URLResourceKey], + skipHiddenFiles: Bool +) throws -> [URL] { + let directoryPath = decodedPath(normalizedFileURL(directory).resolvingSymlinksInPath()) + let baseURL = URL.with(filePath: directoryPath) + let entries = try directoryEntries(atPath: directoryPath) + let requestedKeys = Set(keys) + + return try entries.compactMap { entry in + guard !skipHiddenFiles || !entry.hasPrefix(".") else { return nil } + let url = baseURL.appendingPath(entry) + if !requestedKeys.isEmpty { + _ = try url.resourceValues(forKeys: requestedKeys) + } + return url + } +} + +private func directoryEntries(atPath path: String) throws -> [String] { + #if os(Windows) + var entries: [String] = [] + var findData = WIN32_FIND_DATAW() + let searchPath = "\(path.replacingOccurrences(of: "/", with: "\\"))\\*" + let handle = searchPath.withCString(encodedAs: UTF16.self) { wpath in + FindFirstFileW(wpath, &findData) + } + guard handle != INVALID_HANDLE_VALUE else { + throw NSError(domain: "WinSDK", code: Int(GetLastError())) + } + defer { FindClose(handle) } + + repeat { + let entry = withUnsafePointer(to: &findData.cFileName) { pointer in + pointer.withMemoryRebound( + to: WCHAR.self, + capacity: MemoryLayout.size(ofValue: findData.cFileName) / MemoryLayout.size + ) { + String(decodingCString: $0, as: UTF16.self) + } + } + guard entry != ".", entry != ".." else { continue } + entries.append(entry) + } while FindNextFileW(handle, &findData) != 0 + + let lastError = GetLastError() + if lastError != DWORD(ERROR_NO_MORE_FILES) { + throw NSError(domain: "WinSDK", code: Int(lastError)) + } + + return entries + #else + guard let directory = opendir(path) else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) + } + defer { closedir(directory) } + + var entries: [String] = [] + errno = 0 + while let entryPointer = readdir(directory) { + let entry = entryPointer.pointee + var entryName = entry.d_name + let capacity = MemoryLayout.size(ofValue: entryName) / MemoryLayout.size + let name = withUnsafePointer(to: &entryName) { pointer in + pointer.withMemoryRebound(to: CChar.self, capacity: capacity) { + String(cString: $0) + } + } + guard name != ".", name != ".." else { continue } + entries.append(name) + } + if errno != 0 { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) + } + return entries + #endif +} + +private func normalizedFileURL(_ url: URL) -> URL { + if url.isFileURL { + return url + } + return URL.with(filePath: url.absoluteString.removingPercentEncoding ?? url.absoluteString) +} + +private func decodedPath(_ url: URL) -> String { + let path = url.path() + return path.removingPercentEncoding ?? path +} + extension URL { public static func with(filePath: String) -> URL { #if os(Linux) From 6dd401f588164dcc98f0b277cde49ccf6f0768e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Pi=C3=B1era?= Date: Tue, 17 Mar 2026 11:59:52 +0100 Subject: [PATCH 03/19] fix cross-platform CI --- Sources/FileSystem/FileSystem.swift | 73 ++++++++++++++++++++--------- Sources/Glob/GlobSearch.swift | 15 ++++-- 2 files changed, 63 insertions(+), 25 deletions(-) diff --git a/Sources/FileSystem/FileSystem.swift b/Sources/FileSystem/FileSystem.swift index 38c4d63..bf0e9bd 100644 --- a/Sources/FileSystem/FileSystem.swift +++ b/Sources/FileSystem/FileSystem.swift @@ -371,7 +371,7 @@ public struct FileSystem: FileSysteming, Sendable { } public func currentWorkingDirectory() async throws -> AbsolutePath { - return try AbsolutePath(validating: try platformCurrentWorkingDirectoryPath()) + try AbsolutePath(validating: try platformCurrentWorkingDirectoryPath()) } public func contentsOfDirectory(_ path: AbsolutePath) async throws -> [AbsolutePath] { @@ -661,18 +661,16 @@ public struct FileSystem: FileSysteming, Sendable { lastAccessDate: Date?, lastModificationDate: Date? ) throws { - var times = [ - timespec(tv_sec: 0, tv_nsec: Int(UTIME_OMIT)), - timespec(tv_sec: 0, tv_nsec: Int(UTIME_OMIT)), - ] - - if let lastAccessDate { - times[0] = dateToTimespec(lastAccessDate) + var info = stat() + let statResult = path.withCString { stat($0, &info) } + guard statResult == 0 else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) } - if let lastModificationDate { - times[1] = dateToTimespec(lastModificationDate) - } + var times = [ + dateToTimespec(lastAccessDate ?? date(from: accessTimespec(from: info))), + dateToTimespec(lastModificationDate ?? date(from: modificationTimespec(from: info))), + ] let result = path.withCString { pathPointer in utimensat(AT_FDCWD, pathPointer, ×, 0) @@ -688,6 +686,28 @@ public struct FileSystem: FileSysteming, Sendable { let nanoseconds = Int((date.timeIntervalSince1970 - Double(seconds)) * 1_000_000_000) return timespec(tv_sec: seconds, tv_nsec: nanoseconds) } + + private static func date(from timespec: timespec) -> Date { + let seconds = TimeInterval(timespec.tv_sec) + let nanoseconds = TimeInterval(timespec.tv_nsec) / 1_000_000_000 + return Date(timeIntervalSince1970: seconds + nanoseconds) + } + + private static func accessTimespec(from info: stat) -> timespec { + #if canImport(Darwin) + info.st_atimespec + #else + info.st_atim + #endif + } + + private static func modificationTimespec(from info: stat) -> timespec { + #if canImport(Darwin) + info.st_mtimespec + #else + info.st_mtim + #endif + } #endif public func locateTraversingUp(from: AbsolutePath, relativePath: RelativePath) async throws -> AbsolutePath? { @@ -939,7 +959,7 @@ extension FileSystem { let name = windowsDirectoryEntryName(from: &findData) guard name != ".", name != ".." else { continue } entries.append(path.appending(component: name)) - } while FindNextFileW(handle, &findData) != 0 + } while windowsSucceeded(FindNextFileW(handle, &findData)) let lastError = GetLastError() if lastError != DWORD(ERROR_NO_MORE_FILES) { @@ -1028,17 +1048,17 @@ extension FileSystem { let success = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { RemoveDirectoryW($0) } - guard success != 0 else { throw windowsError() } + guard windowsSucceeded(success) else { throw windowsError() } } else if isDirectory { let success = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { RemoveDirectoryW($0) } - guard success != 0 else { throw windowsError() } + guard windowsSucceeded(success) else { throw windowsError() } } else { let success = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { DeleteFileW($0) } - guard success != 0 else { throw windowsError() } + guard windowsSucceeded(success) else { throw windowsError() } } #else guard let info = try platformFileInfo(at: path, followSymlinks: false) else { return } @@ -1090,7 +1110,8 @@ extension FileSystem { let basePath = try AbsolutePath(validating: systemTemporaryDirectory) var template = basePath.appending(component: "\(prefix)-XXXXXX").pathString.utf8CString let createdPath = template.withUnsafeMutableBufferPointer { pointer -> String? in - guard let pathPointer = mkdtemp(pointer.baseAddress) else { return nil } + guard let baseAddress = pointer.baseAddress else { return nil } + guard let pathPointer = mkdtemp(baseAddress) else { return nil } return String(cString: pathPointer) } guard let createdPath else { throw posixError() } @@ -1109,7 +1130,7 @@ extension FileSystem { MoveFileExW(wsrc, wdst, DWORD(MOVEFILE_COPY_ALLOWED)) } } - guard success != 0 else { throw windowsError() } + guard windowsSucceeded(success) else { throw windowsError() } #else let result = from.pathString.withCString { sourcePointer in to.pathString.withCString { destinationPointer in @@ -1143,7 +1164,7 @@ extension FileSystem { let success = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { wpath in CreateDirectoryW(wpath, nil) } - guard success != 0 else { + guard windowsSucceeded(success) else { let error = GetLastError() if error == DWORD(ERROR_ALREADY_EXISTS), try platformItemExists(at: path, isDirectory: true) { return @@ -1197,7 +1218,7 @@ extension FileSystem { CopyFileW(wsrc, wdst, true) } } - guard success != 0 else { throw windowsError() } + guard windowsSucceeded(success) else { throw windowsError() } #else try platformCopyRegularFile(from: from, to: to) #endif @@ -1232,7 +1253,7 @@ extension FileSystem { var accessTime = lastAccessDate.map(windowsFileTime(from:)) var modificationTime = lastModificationDate.map(windowsFileTime(from:)) let success = SetFileTime(handle, nil, &accessTime, &modificationTime) - guard success != 0 else { throw windowsError() } + guard windowsSucceeded(success) else { throw windowsError() } #else try Self.updateFileTimes( path: path.pathString, @@ -1247,7 +1268,7 @@ extension FileSystem { var flags = DWORD(0x2) let targetAttributes = windowsAttributes(atPath: toPathString) if targetAttributes != INVALID_FILE_ATTRIBUTES, - (targetAttributes & DWORD(FILE_ATTRIBUTE_DIRECTORY)) != 0 + (targetAttributes & DWORD(FILE_ATTRIBUTE_DIRECTORY)) != 0 { flags |= DWORD(SYMBOLIC_LINK_FLAG_DIRECTORY) } @@ -1256,7 +1277,7 @@ extension FileSystem { CreateSymbolicLinkW(wlink, wtarget, flags) } } - guard success != 0 else { throw windowsError() } + guard windowsSucceeded(success) else { throw windowsError() } #else let result = fromPathString.withCString { linkPointer in toPathString.withCString { targetPointer in @@ -1452,6 +1473,14 @@ extension FileSystem { } } + private func windowsSucceeded(_ result: Bool) -> Bool { + result + } + + private func windowsSucceeded(_ result: some BinaryInteger) -> Bool { + result != 0 + } + private func windowsPathString(_ path: String) -> String { path.replacingOccurrences(of: "/", with: "\\") } diff --git a/Sources/Glob/GlobSearch.swift b/Sources/Glob/GlobSearch.swift index 17a7cf2..02c91a9 100644 --- a/Sources/Glob/GlobSearch.swift +++ b/Sources/Glob/GlobSearch.swift @@ -94,8 +94,7 @@ public func search( let symbolicLinkDestinationPath = decodedPath(symbolicLinkDestination) - guard - let resourceValues = try? URL.with(filePath: symbolicLinkDestinationPath) + guard let resourceValues = try? URL.with(filePath: symbolicLinkDestinationPath) .resourceValues(forKeys: [.isDirectoryKey]), resourceValues.isDirectory == true else { continue } @@ -291,7 +290,7 @@ private func directoryEntries(atPath path: String) throws -> [String] { } guard entry != ".", entry != ".." else { continue } entries.append(entry) - } while FindNextFileW(handle, &findData) != 0 + } while windowsSucceeded(FindNextFileW(handle, &findData)) let lastError = GetLastError() if lastError != DWORD(ERROR_NO_MORE_FILES) { @@ -338,6 +337,16 @@ private func decodedPath(_ url: URL) -> String { return path.removingPercentEncoding ?? path } +#if os(Windows) + private func windowsSucceeded(_ result: Bool) -> Bool { + result + } + + private func windowsSucceeded(_ result: some BinaryInteger) -> Bool { + result != 0 + } +#endif + extension URL { public static func with(filePath: String) -> URL { #if os(Linux) From 6f7481abf2e5e966c4d0816a291d157b573a3af9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Pi=C3=B1era?= Date: Tue, 17 Mar 2026 14:12:25 +0100 Subject: [PATCH 04/19] fix windows directory iteration --- Sources/FileSystem/FileSystem.swift | 7 ++++--- Sources/Glob/GlobSearch.swift | 21 +++++++++++++-------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/Sources/FileSystem/FileSystem.swift b/Sources/FileSystem/FileSystem.swift index bf0e9bd..96d22d0 100644 --- a/Sources/FileSystem/FileSystem.swift +++ b/Sources/FileSystem/FileSystem.swift @@ -1485,11 +1485,12 @@ extension FileSystem { path.replacingOccurrences(of: "/", with: "\\") } - private func windowsDirectoryEntryName(from findData: inout WIN32_FIND_DATAW) -> String { - withUnsafePointer(to: &findData.cFileName) { pointer in + private func windowsDirectoryEntryName(from findData: WIN32_FIND_DATAW) -> String { + var fileName = findData.cFileName + return withUnsafePointer(to: &fileName) { pointer in pointer.withMemoryRebound( to: WCHAR.self, - capacity: MemoryLayout.size(ofValue: findData.cFileName) / MemoryLayout.size + capacity: MemoryLayout.size(ofValue: fileName) / MemoryLayout.size ) { String(decodingCString: $0, as: UTF16.self) } diff --git a/Sources/Glob/GlobSearch.swift b/Sources/Glob/GlobSearch.swift index 02c91a9..3df131d 100644 --- a/Sources/Glob/GlobSearch.swift +++ b/Sources/Glob/GlobSearch.swift @@ -280,14 +280,7 @@ private func directoryEntries(atPath path: String) throws -> [String] { defer { FindClose(handle) } repeat { - let entry = withUnsafePointer(to: &findData.cFileName) { pointer in - pointer.withMemoryRebound( - to: WCHAR.self, - capacity: MemoryLayout.size(ofValue: findData.cFileName) / MemoryLayout.size - ) { - String(decodingCString: $0, as: UTF16.self) - } - } + let entry = windowsDirectoryEntryName(from: findData) guard entry != ".", entry != ".." else { continue } entries.append(entry) } while windowsSucceeded(FindNextFileW(handle, &findData)) @@ -345,6 +338,18 @@ private func decodedPath(_ url: URL) -> String { private func windowsSucceeded(_ result: some BinaryInteger) -> Bool { result != 0 } + + private func windowsDirectoryEntryName(from findData: WIN32_FIND_DATAW) -> String { + var fileName = findData.cFileName + return withUnsafePointer(to: &fileName) { pointer in + pointer.withMemoryRebound( + to: WCHAR.self, + capacity: MemoryLayout.size(ofValue: fileName) / MemoryLayout.size + ) { + String(decodingCString: $0, as: UTF16.self) + } + } + } #endif extension URL { From 2c2011641529536b65087882c87dd179393d51c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Pi=C3=B1era?= Date: Tue, 17 Mar 2026 15:11:54 +0100 Subject: [PATCH 05/19] fix windows filename decoding --- Sources/FileSystem/FileSystem.swift | 3 ++- Sources/Glob/GlobSearch.swift | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/FileSystem/FileSystem.swift b/Sources/FileSystem/FileSystem.swift index 96d22d0..5310ab6 100644 --- a/Sources/FileSystem/FileSystem.swift +++ b/Sources/FileSystem/FileSystem.swift @@ -1487,10 +1487,11 @@ extension FileSystem { private func windowsDirectoryEntryName(from findData: WIN32_FIND_DATAW) -> String { var fileName = findData.cFileName + let capacity = MemoryLayout.size(ofValue: fileName) / MemoryLayout.size return withUnsafePointer(to: &fileName) { pointer in pointer.withMemoryRebound( to: WCHAR.self, - capacity: MemoryLayout.size(ofValue: fileName) / MemoryLayout.size + capacity: capacity ) { String(decodingCString: $0, as: UTF16.self) } diff --git a/Sources/Glob/GlobSearch.swift b/Sources/Glob/GlobSearch.swift index 3df131d..ef6131a 100644 --- a/Sources/Glob/GlobSearch.swift +++ b/Sources/Glob/GlobSearch.swift @@ -341,10 +341,11 @@ private func decodedPath(_ url: URL) -> String { private func windowsDirectoryEntryName(from findData: WIN32_FIND_DATAW) -> String { var fileName = findData.cFileName + let capacity = MemoryLayout.size(ofValue: fileName) / MemoryLayout.size return withUnsafePointer(to: &fileName) { pointer in pointer.withMemoryRebound( to: WCHAR.self, - capacity: MemoryLayout.size(ofValue: fileName) / MemoryLayout.size + capacity: capacity ) { String(decodingCString: $0, as: UTF16.self) } From 1d943d2633571c8affbdb722722f7d5f6277b862 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Pi=C3=B1era?= Date: Tue, 17 Mar 2026 15:21:31 +0100 Subject: [PATCH 06/19] fix windows file time updates --- Sources/FileSystem/FileSystem.swift | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/Sources/FileSystem/FileSystem.swift b/Sources/FileSystem/FileSystem.swift index 5310ab6..a8816e1 100644 --- a/Sources/FileSystem/FileSystem.swift +++ b/Sources/FileSystem/FileSystem.swift @@ -956,7 +956,7 @@ extension FileSystem { defer { FindClose(handle) } repeat { - let name = windowsDirectoryEntryName(from: &findData) + let name = windowsDirectoryEntryName(from: findData) guard name != ".", name != ".." else { continue } entries.append(path.appending(component: name)) } while windowsSucceeded(FindNextFileW(handle, &findData)) @@ -1250,9 +1250,22 @@ extension FileSystem { guard let handle, handle != INVALID_HANDLE_VALUE else { throw windowsError() } defer { CloseHandle(handle) } - var accessTime = lastAccessDate.map(windowsFileTime(from:)) - var modificationTime = lastModificationDate.map(windowsFileTime(from:)) - let success = SetFileTime(handle, nil, &accessTime, &modificationTime) + let accessTime = lastAccessDate.map(windowsFileTime(from:)) + let modificationTime = lastModificationDate.map(windowsFileTime(from:)) + let success: Bool + if let accessTime, let modificationTime { + var accessTime = accessTime + var modificationTime = modificationTime + success = windowsSucceeded(SetFileTime(handle, nil, &accessTime, &modificationTime)) + } else if let accessTime { + var accessTime = accessTime + success = windowsSucceeded(SetFileTime(handle, nil, &accessTime, nil)) + } else if let modificationTime { + var modificationTime = modificationTime + success = windowsSucceeded(SetFileTime(handle, nil, nil, &modificationTime)) + } else { + return + } guard windowsSucceeded(success) else { throw windowsError() } #else try Self.updateFileTimes( From 693260bf4d0b5113c81d065ec278c24f7321cb21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Pi=C3=B1era?= Date: Tue, 17 Mar 2026 15:35:00 +0100 Subject: [PATCH 07/19] refactor filesystem helper naming --- Sources/FileSystem/FileSystem.swift | 154 ++++++++++++++-------------- 1 file changed, 77 insertions(+), 77 deletions(-) diff --git a/Sources/FileSystem/FileSystem.swift b/Sources/FileSystem/FileSystem.swift index a8816e1..ba93c99 100644 --- a/Sources/FileSystem/FileSystem.swift +++ b/Sources/FileSystem/FileSystem.swift @@ -371,16 +371,16 @@ public struct FileSystem: FileSysteming, Sendable { } public func currentWorkingDirectory() async throws -> AbsolutePath { - try AbsolutePath(validating: try platformCurrentWorkingDirectoryPath()) + try AbsolutePath(validating: try currentWorkingDirectoryPath()) } public func contentsOfDirectory(_ path: AbsolutePath) async throws -> [AbsolutePath] { - try platformDirectoryContents(at: path) + try directoryContents(at: path) } public func exists(_ path: AbsolutePath) async throws -> Bool { logger?.debug("Checking if a file or directory exists at path \(path.pathString).") - return try platformItemExists(at: path) + return try itemExists(at: path) } public func exists(_ path: AbsolutePath, isDirectory: Bool) async throws -> Bool { @@ -389,33 +389,33 @@ public struct FileSystem: FileSysteming, Sendable { } else { logger?.debug("Checking if a file exists at path \(path.pathString).") } - return try platformItemExists(at: path, isDirectory: isDirectory) + return try itemExists(at: path, isDirectory: isDirectory) } public func touch(_ path: Path.AbsolutePath) async throws { logger?.debug("Touching a file at path \(path.pathString).") - if try platformItemExists(at: path) { + if try itemExists(at: path) { let now = Date() try await setFileTimes(of: path, lastAccessDate: now, lastModificationDate: now) return } - guard try platformItemExists(at: path.parentDirectory, isDirectory: true) else { + guard try itemExists(at: path.parentDirectory, isDirectory: true) else { throw CocoaError(.fileNoSuchFile) } - try platformCreateEmptyFile(at: path) + try createEmptyFile(at: path) } public func remove(_ path: AbsolutePath) async throws { logger?.debug("Removing the file or directory at path: \(path.pathString).") guard try await exists(path) else { return } - try platformRemoveItem(at: path) + try removeItem(at: path) } public func makeTemporaryDirectory(prefix: String) async throws -> AbsolutePath { - let temporaryDirectory = try platformMakeTemporaryDirectory(prefix: prefix) + let temporaryDirectory = try makeTemporaryDirectoryPath(prefix: prefix) logger?.debug("Creating a temporary directory at path \(temporaryDirectory.pathString).") return temporaryDirectory } @@ -441,7 +441,7 @@ public struct FileSystem: FileSysteming, Sendable { guard try await exists(from) else { throw FileSystemError.moveNotFound(from: from, to: to) } - try platformMoveItem(from: from, to: to) + try moveItem(from: from, to: to) } public func makeDirectory(at: Path.AbsolutePath) async throws { @@ -461,7 +461,7 @@ public struct FileSystem: FileSysteming, Sendable { if !createIntermediates, !(try await exists(at.parentDirectory, isDirectory: true)) { throw FileSystemError.makeDirectoryAbsentParent(at) } - try platformCreateDirectory(at: at, createIntermediates: createIntermediates) + try createDirectory(at: at, createIntermediates: createIntermediates) } public func readFile(at path: Path.AbsolutePath) async throws -> Data { @@ -588,10 +588,10 @@ public struct FileSystem: FileSysteming, Sendable { if !(try await exists(to.parentDirectory)) { try await makeDirectory(at: to.parentDirectory) } - if try platformItemExists(at: to, followSymlinks: false) { - try platformRemoveItem(at: to) + if try itemExists(at: to, followSymlinks: false) { + try removeItem(at: to) } - try platformCopyItem(from: path, to: to) + try copyItem(from: path, to: to) } public func copy(_ from: AbsolutePath, to: AbsolutePath) async throws { @@ -602,7 +602,7 @@ public struct FileSystem: FileSysteming, Sendable { if !(try await exists(to.parentDirectory)) { try await makeDirectory(at: to.parentDirectory) } - try platformCopyItem(from: from, to: to) + try copyItem(from: from, to: to) } public func runInTemporaryDirectory( @@ -639,7 +639,7 @@ public struct FileSystem: FileSysteming, Sendable { public func fileMetadata(at path: AbsolutePath) async throws -> FileMetadata? { logger?.debug("Getting the metadata of file at path \(path.pathString).") - return try platformFileMetadata(at: path) + return try metadata(at: path) } public func setFileTimes( @@ -648,7 +648,7 @@ public struct FileSystem: FileSysteming, Sendable { lastModificationDate: Date? ) async throws { logger?.debug("Setting file times at path \(path.pathString).") - try platformSetFileTimes( + try updateItemTimes( of: path, lastAccessDate: lastAccessDate, lastModificationDate: lastModificationDate @@ -730,7 +730,7 @@ public struct FileSystem: FileSysteming, Sendable { private func createSymbolicLink(fromPathString: String, toPathString: String) async throws { logger?.debug("Creating symbolic link from \(fromPathString) to \(toPathString).") - try platformCreateSymbolicLink(fromPathString: fromPathString, toPathString: toPathString) + try makeSymbolicLink(fromPathString: fromPathString, toPathString: toPathString) } public func resolveSymbolicLink(_ symlinkPath: AbsolutePath) async throws -> AbsolutePath { @@ -740,7 +740,7 @@ public struct FileSystem: FileSysteming, Sendable { } let destination: String do { - destination = try platformReadSymbolicLink(at: symlinkPath) + destination = try readSymbolicLinkDestination(at: symlinkPath) } catch { return symlinkPath } @@ -855,15 +855,15 @@ extension FileSystem { private func createArchive(at sourceURL: URL, to destinationURL: URL, shouldKeepParent: Bool) throws { let sourcePath = try AbsolutePath(validating: sourceURL.path) let destinationPath = try AbsolutePath(validating: destinationURL.path) - guard try platformItemExists(at: sourcePath) else { + guard try itemExists(at: sourcePath) else { throw CocoaError(.fileReadNoSuchFile, userInfo: [NSFilePathErrorKey: sourceURL.path]) } - guard try !platformItemExists(at: destinationPath, followSymlinks: false) else { + guard try !itemExists(at: destinationPath, followSymlinks: false) else { throw CocoaError(.fileWriteFileExists, userInfo: [NSFilePathErrorKey: destinationURL.path]) } let archive = try Archive(url: destinationURL, accessMode: .create) - if try platformItemExists(at: sourcePath, isDirectory: true) { + if try itemExists(at: sourcePath, isDirectory: true) { let baseURL = shouldKeepParent ? URL(fileURLWithPath: sourcePath.parentDirectory.pathString) : sourceURL @@ -886,7 +886,7 @@ extension FileSystem { private func extractArchive(at sourceURL: URL, to destinationURL: URL) throws { let sourcePath = try AbsolutePath(validating: sourceURL.path) - guard try platformItemExists(at: sourcePath) else { + guard try itemExists(at: sourcePath) else { throw CocoaError(.fileReadNoSuchFile, userInfo: [NSFilePathErrorKey: sourceURL.path]) } @@ -902,21 +902,21 @@ extension FileSystem { #endif } -private enum PlatformFileKind { +private enum FileKind { case directory case file case symbolicLink case other } -private struct PlatformFileInfo { - let kind: PlatformFileKind +private struct FileInfo { + let kind: FileKind let size: Int64 let modificationDate: Date } extension FileSystem { - private func platformCurrentWorkingDirectoryPath() throws -> String { + private func currentWorkingDirectoryPath() throws -> String { #if os(Windows) var buffer = [WCHAR](repeating: 0, count: Int(MAX_PATH) + 1) var length = buffer.withUnsafeMutableBufferPointer { @@ -940,9 +940,9 @@ extension FileSystem { #endif } - private func platformDirectoryContents(at path: AbsolutePath) throws -> [AbsolutePath] { + private func directoryContents(at path: AbsolutePath) throws -> [AbsolutePath] { #if os(Windows) - guard try platformItemExists(at: path, isDirectory: true) else { + guard try itemExists(at: path, isDirectory: true) else { throw windowsError(DWORD(ERROR_PATH_NOT_FOUND)) } @@ -997,20 +997,20 @@ extension FileSystem { #endif } - private func platformItemExists(at path: AbsolutePath) throws -> Bool { - try platformItemExists(at: path, followSymlinks: true) + private func itemExists(at path: AbsolutePath) throws -> Bool { + try itemExists(at: path, followSymlinks: true) } - private func platformItemExists(at path: AbsolutePath, isDirectory: Bool) throws -> Bool { - guard let info = try platformFileInfo(at: path, followSymlinks: true) else { return false } + private func itemExists(at path: AbsolutePath, isDirectory: Bool) throws -> Bool { + guard let info = try fileInfo(at: path, followSymlinks: true) else { return false } return info.kind == (isDirectory ? .directory : .file) } - private func platformItemExists(at path: AbsolutePath, followSymlinks: Bool) throws -> Bool { - try platformFileInfo(at: path, followSymlinks: followSymlinks) != nil + private func itemExists(at path: AbsolutePath, followSymlinks: Bool) throws -> Bool { + try fileInfo(at: path, followSymlinks: followSymlinks) != nil } - private func platformCreateEmptyFile(at path: AbsolutePath) throws { + private func createEmptyFile(at path: AbsolutePath) throws { #if os(Windows) let handle = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { wpath in CreateFileW( @@ -1034,7 +1034,7 @@ extension FileSystem { #endif } - private func platformRemoveItem(at path: AbsolutePath) throws { + private func removeItem(at path: AbsolutePath) throws { #if os(Windows) let attributes = windowsAttributes(atPath: path.pathString) guard attributes != INVALID_FILE_ATTRIBUTES else { return } @@ -1042,8 +1042,8 @@ extension FileSystem { let isDirectory = (attributes & DWORD(FILE_ATTRIBUTE_DIRECTORY)) != 0 let isReparsePoint = (attributes & DWORD(FILE_ATTRIBUTE_REPARSE_POINT)) != 0 if isDirectory, !isReparsePoint { - for child in try platformDirectoryContents(at: path) { - try platformRemoveItem(at: child) + for child in try directoryContents(at: path) { + try removeItem(at: child) } let success = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { RemoveDirectoryW($0) @@ -1061,11 +1061,11 @@ extension FileSystem { guard windowsSucceeded(success) else { throw windowsError() } } #else - guard let info = try platformFileInfo(at: path, followSymlinks: false) else { return } + guard let info = try fileInfo(at: path, followSymlinks: false) else { return } switch info.kind { case .directory: - for child in try platformDirectoryContents(at: path) { - try platformRemoveItem(at: child) + for child in try directoryContents(at: path) { + try removeItem(at: child) } let result = path.pathString.withCString { rmdir($0) } guard result == 0 else { throw posixError() } @@ -1076,7 +1076,7 @@ extension FileSystem { #endif } - private func platformMakeTemporaryDirectory(prefix: String) throws -> AbsolutePath { + private func makeTemporaryDirectoryPath(prefix: String) throws -> AbsolutePath { #if os(Windows) var buffer = [WCHAR](repeating: 0, count: Int(MAX_PATH) + 1) var length = buffer.withUnsafeMutableBufferPointer { @@ -1096,7 +1096,7 @@ extension FileSystem { } ) let path = temporaryDirectory.appending(component: "\(prefix)-\(UUID().uuidString)") - try platformCreateDirectory(at: path, createIntermediates: true) + try createDirectory(at: path, createIntermediates: true) return path #else var systemTemporaryDirectory = NSTemporaryDirectory() @@ -1119,8 +1119,8 @@ extension FileSystem { #endif } - private func platformMoveItem(from: AbsolutePath, to: AbsolutePath) throws { - if try platformItemExists(at: to, followSymlinks: false) { + private func moveItem(from: AbsolutePath, to: AbsolutePath) throws { + if try itemExists(at: to, followSymlinks: false) { throw fileExistsError(at: to) } @@ -1140,8 +1140,8 @@ extension FileSystem { guard result == 0 else { let error = errno if error == EXDEV { - try platformCopyItem(from: from, to: to) - try platformRemoveItem(at: from) + try copyItem(from: from, to: to) + try removeItem(at: from) return } throw posixError(error) @@ -1149,16 +1149,16 @@ extension FileSystem { #endif } - private func platformCreateDirectory(at path: AbsolutePath, createIntermediates: Bool) throws { + private func createDirectory(at path: AbsolutePath, createIntermediates: Bool) throws { #if os(Windows) - if let existing = try platformFileInfo(at: path, followSymlinks: false) { + if let existing = try fileInfo(at: path, followSymlinks: false) { guard existing.kind == .directory else { throw windowsError(DWORD(ERROR_ALREADY_EXISTS)) } return } if createIntermediates { let parent = path.parentDirectory if parent != path { - try platformCreateDirectory(at: parent, createIntermediates: true) + try createDirectory(at: parent, createIntermediates: true) } } let success = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { wpath in @@ -1166,26 +1166,26 @@ extension FileSystem { } guard windowsSucceeded(success) else { let error = GetLastError() - if error == DWORD(ERROR_ALREADY_EXISTS), try platformItemExists(at: path, isDirectory: true) { + if error == DWORD(ERROR_ALREADY_EXISTS), try itemExists(at: path, isDirectory: true) { return } throw windowsError(error) } #else - if let existing = try platformFileInfo(at: path, followSymlinks: false) { + if let existing = try fileInfo(at: path, followSymlinks: false) { guard existing.kind == .directory else { throw posixError(EEXIST) } return } if createIntermediates { let parent = path.parentDirectory if parent != path { - try platformCreateDirectory(at: parent, createIntermediates: true) + try createDirectory(at: parent, createIntermediates: true) } } let result = path.pathString.withCString { mkdir($0, mode_t(0o755)) } guard result == 0 else { let error = errno - if error == EEXIST, try platformItemExists(at: path, isDirectory: true) { + if error == EEXIST, try itemExists(at: path, isDirectory: true) { return } throw posixError(error) @@ -1193,24 +1193,24 @@ extension FileSystem { #endif } - private func platformCopyItem(from: AbsolutePath, to: AbsolutePath) throws { - if try platformItemExists(at: to, followSymlinks: false) { + private func copyItem(from: AbsolutePath, to: AbsolutePath) throws { + if try itemExists(at: to, followSymlinks: false) { throw fileExistsError(at: to) } - guard let info = try platformFileInfo(at: from, followSymlinks: false) else { + guard let info = try fileInfo(at: from, followSymlinks: false) else { throw CocoaError(.fileReadNoSuchFile, userInfo: [NSFilePathErrorKey: from.pathString]) } switch info.kind { case .directory: - try platformCreateDirectory(at: to, createIntermediates: false) - for child in try platformDirectoryContents(at: from) { - try platformCopyItem(from: child, to: to.appending(component: child.basename)) + try createDirectory(at: to, createIntermediates: false) + for child in try directoryContents(at: from) { + try copyItem(from: child, to: to.appending(component: child.basename)) } case .symbolicLink: - let destination = try platformReadSymbolicLink(at: from) - try platformCreateSymbolicLink(fromPathString: to.pathString, toPathString: destination) + let destination = try readSymbolicLinkDestination(at: from) + try makeSymbolicLink(fromPathString: to.pathString, toPathString: destination) case .file, .other: #if os(Windows) let success = windowsPathString(from.pathString).withCString(encodedAs: UTF16.self) { wsrc in @@ -1220,17 +1220,17 @@ extension FileSystem { } guard windowsSucceeded(success) else { throw windowsError() } #else - try platformCopyRegularFile(from: from, to: to) + try copyRegularFile(from: from, to: to) #endif } } - private func platformFileMetadata(at path: AbsolutePath) throws -> FileMetadata? { - guard let info = try platformFileInfo(at: path, followSymlinks: true) else { return nil } + private func metadata(at path: AbsolutePath) throws -> FileMetadata? { + guard let info = try fileInfo(at: path, followSymlinks: true) else { return nil } return FileMetadata(size: info.size, lastModificationDate: info.modificationDate) } - private func platformSetFileTimes( + private func updateItemTimes( of path: AbsolutePath, lastAccessDate: Date?, lastModificationDate: Date? @@ -1276,7 +1276,7 @@ extension FileSystem { #endif } - private func platformCreateSymbolicLink(fromPathString: String, toPathString: String) throws { + private func makeSymbolicLink(fromPathString: String, toPathString: String) throws { #if os(Windows) var flags = DWORD(0x2) let targetAttributes = windowsAttributes(atPath: toPathString) @@ -1301,7 +1301,7 @@ extension FileSystem { #endif } - private func platformReadSymbolicLink(at path: AbsolutePath) throws -> String { + private func readSymbolicLinkDestination(at path: AbsolutePath) throws -> String { #if os(Windows) let handle = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { wpath in CreateFileW( @@ -1354,18 +1354,18 @@ extension FileSystem { private func descendantRelativePaths(of directory: AbsolutePath, prefix: String) throws -> [String] { var descendants: [String] = [] - for child in try platformDirectoryContents(at: directory) { + for child in try directoryContents(at: directory) { let relativePath = prefix.isEmpty ? child.basename : "\(prefix)/\(child.basename)" descendants.append(relativePath) - if try platformFileInfo(at: child, followSymlinks: false)?.kind == .directory { + if try fileInfo(at: child, followSymlinks: false)?.kind == .directory { descendants.append(contentsOf: try descendantRelativePaths(of: child, prefix: relativePath)) } } return descendants } - private func platformFileInfo(at path: AbsolutePath, followSymlinks: Bool) throws -> PlatformFileInfo? { + private func fileInfo(at path: AbsolutePath, followSymlinks: Bool) throws -> FileInfo? { #if os(Windows) var findData = WIN32_FIND_DATAW() let handle = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { wpath in @@ -1381,7 +1381,7 @@ extension FileSystem { defer { FindClose(handle) } let attributes = findData.dwFileAttributes - let kind: PlatformFileKind + let kind: FileKind if (attributes & DWORD(FILE_ATTRIBUTE_DIRECTORY)) != 0 { kind = .directory } else if (attributes & DWORD(FILE_ATTRIBUTE_REPARSE_POINT)) != 0 { @@ -1390,7 +1390,7 @@ extension FileSystem { kind = .file } let size = (Int64(findData.nFileSizeHigh) << 32) | Int64(findData.nFileSizeLow) - return PlatformFileInfo( + return FileInfo( kind: kind, size: size, modificationDate: windowsDate(from: findData.ftLastWriteTime) @@ -1412,7 +1412,7 @@ extension FileSystem { throw posixError(error) } - return PlatformFileInfo( + return FileInfo( kind: posixFileKind(from: info), size: Int64(info.st_size), modificationDate: posixModificationDate(from: info) @@ -1421,7 +1421,7 @@ extension FileSystem { } #if !os(Windows) - private func platformCopyRegularFile(from: AbsolutePath, to: AbsolutePath) throws { + private func copyRegularFile(from: AbsolutePath, to: AbsolutePath) throws { let sourceDescriptor = from.pathString.withCString { open($0, O_RDONLY) } guard sourceDescriptor >= 0 else { throw posixError() } defer { _ = close(sourceDescriptor) } @@ -1452,7 +1452,7 @@ extension FileSystem { } } - private func posixFileKind(from info: stat) -> PlatformFileKind { + private func posixFileKind(from info: stat) -> FileKind { switch info.st_mode & S_IFMT { case S_IFDIR: return .directory From d49eccf39c8f8f05eff0437fdc2fd370c39ce79e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Pi=C3=B1era?= Date: Tue, 17 Mar 2026 15:43:35 +0100 Subject: [PATCH 08/19] refactor filesystem internal api shape --- Sources/FileSystem/FileSystem.swift | 354 +++++++++++++--------------- 1 file changed, 164 insertions(+), 190 deletions(-) diff --git a/Sources/FileSystem/FileSystem.swift b/Sources/FileSystem/FileSystem.swift index ba93c99..ee58d9d 100644 --- a/Sources/FileSystem/FileSystem.swift +++ b/Sources/FileSystem/FileSystem.swift @@ -371,16 +371,36 @@ public struct FileSystem: FileSysteming, Sendable { } public func currentWorkingDirectory() async throws -> AbsolutePath { - try AbsolutePath(validating: try currentWorkingDirectoryPath()) + #if os(Windows) + var buffer = [WCHAR](repeating: 0, count: Int(MAX_PATH) + 1) + var length = buffer.withUnsafeMutableBufferPointer { + GetCurrentDirectoryW(DWORD($0.count), $0.baseAddress) + } + guard length > 0 else { throw windowsError() } + if Int(length) >= buffer.count { + buffer = [WCHAR](repeating: 0, count: Int(length) + 1) + length = buffer.withUnsafeMutableBufferPointer { + GetCurrentDirectoryW(DWORD($0.count), $0.baseAddress) + } + guard length > 0, Int(length) < buffer.count else { throw windowsError() } + } + return try AbsolutePath(validating: buffer.withUnsafeBufferPointer { + String(decodingCString: $0.baseAddress!, as: UTF16.self) + }) + #else + var buffer = [CChar](repeating: 0, count: Int(PATH_MAX)) + guard getcwd(&buffer, buffer.count) != nil else { throw posixError() } + return try AbsolutePath(validating: String(cString: buffer)) + #endif } public func contentsOfDirectory(_ path: AbsolutePath) async throws -> [AbsolutePath] { - try directoryContents(at: path) + try contentsOfDirectory(at: path) } public func exists(_ path: AbsolutePath) async throws -> Bool { logger?.debug("Checking if a file or directory exists at path \(path.pathString).") - return try itemExists(at: path) + return try exists(path, followSymlinks: true) } public func exists(_ path: AbsolutePath, isDirectory: Bool) async throws -> Bool { @@ -389,33 +409,94 @@ public struct FileSystem: FileSysteming, Sendable { } else { logger?.debug("Checking if a file exists at path \(path.pathString).") } - return try itemExists(at: path, isDirectory: isDirectory) + return try exists(path, as: isDirectory ? .directory : .file) } public func touch(_ path: Path.AbsolutePath) async throws { logger?.debug("Touching a file at path \(path.pathString).") - if try itemExists(at: path) { + if try exists(path, followSymlinks: true) { let now = Date() try await setFileTimes(of: path, lastAccessDate: now, lastModificationDate: now) return } - guard try itemExists(at: path.parentDirectory, isDirectory: true) else { + guard try exists(path.parentDirectory, as: .directory) else { throw CocoaError(.fileNoSuchFile) } - try createEmptyFile(at: path) + #if os(Windows) + let handle = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { wpath in + CreateFileW( + wpath, + DWORD(GENERIC_WRITE), + DWORD(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE), + nil, + DWORD(CREATE_NEW), + DWORD(FILE_ATTRIBUTE_NORMAL), + nil + ) + } + guard let handle, handle != INVALID_HANDLE_VALUE else { throw windowsError() } + CloseHandle(handle) + #else + let descriptor = path.pathString.withCString { pathPointer in + open(pathPointer, O_WRONLY | O_CREAT | O_EXCL, mode_t(0o666)) + } + guard descriptor >= 0 else { throw posixError() } + _ = close(descriptor) + #endif } public func remove(_ path: AbsolutePath) async throws { logger?.debug("Removing the file or directory at path: \(path.pathString).") guard try await exists(path) else { return } - try removeItem(at: path) + try remove(at: path) } public func makeTemporaryDirectory(prefix: String) async throws -> AbsolutePath { - let temporaryDirectory = try makeTemporaryDirectoryPath(prefix: prefix) + let temporaryDirectory: AbsolutePath + #if os(Windows) + var buffer = [WCHAR](repeating: 0, count: Int(MAX_PATH) + 1) + var length = buffer.withUnsafeMutableBufferPointer { + GetTempPathW(DWORD($0.count), $0.baseAddress) + } + guard length > 0 else { throw windowsError() } + if Int(length) >= buffer.count { + buffer = [WCHAR](repeating: 0, count: Int(length) + 1) + length = buffer.withUnsafeMutableBufferPointer { + GetTempPathW(DWORD($0.count), $0.baseAddress) + } + guard length > 0, Int(length) < buffer.count else { throw windowsError() } + } + temporaryDirectory = try AbsolutePath( + validating: buffer.withUnsafeBufferPointer { + String(decodingCString: $0.baseAddress!, as: UTF16.self) + } + ) + let path = temporaryDirectory.appending(component: "\(prefix)-\(UUID().uuidString)") + try makeDirectory(at: path, createIntermediates: true) + logger?.debug("Creating a temporary directory at path \(path.pathString).") + return path + #else + var systemTemporaryDirectory = NSTemporaryDirectory() + + #if os(macOS) + if systemTemporaryDirectory.starts(with: "/var/") { + systemTemporaryDirectory = "/private\(systemTemporaryDirectory)" + } + #endif + + let basePath = try AbsolutePath(validating: systemTemporaryDirectory) + var template = basePath.appending(component: "\(prefix)-XXXXXX").pathString.utf8CString + let createdPath = template.withUnsafeMutableBufferPointer { pointer -> String? in + guard let baseAddress = pointer.baseAddress else { return nil } + guard let pathPointer = mkdtemp(baseAddress) else { return nil } + return String(cString: pathPointer) + } + guard let createdPath else { throw posixError() } + temporaryDirectory = try AbsolutePath(validating: createdPath) + #endif logger?.debug("Creating a temporary directory at path \(temporaryDirectory.pathString).") return temporaryDirectory } @@ -441,7 +522,7 @@ public struct FileSystem: FileSysteming, Sendable { guard try await exists(from) else { throw FileSystemError.moveNotFound(from: from, to: to) } - try moveItem(from: from, to: to) + try move(from: from, to: to, ensureDestinationIsAbsent: true) } public func makeDirectory(at: Path.AbsolutePath) async throws { @@ -461,7 +542,7 @@ public struct FileSystem: FileSysteming, Sendable { if !createIntermediates, !(try await exists(at.parentDirectory, isDirectory: true)) { throw FileSystemError.makeDirectoryAbsentParent(at) } - try createDirectory(at: at, createIntermediates: createIntermediates) + try makeDirectory(at: at, createIntermediates: createIntermediates) } public func readFile(at path: Path.AbsolutePath) async throws -> Data { @@ -588,10 +669,10 @@ public struct FileSystem: FileSysteming, Sendable { if !(try await exists(to.parentDirectory)) { try await makeDirectory(at: to.parentDirectory) } - if try itemExists(at: to, followSymlinks: false) { - try removeItem(at: to) + if try exists(to, followSymlinks: false) { + try remove(at: to) } - try copyItem(from: path, to: to) + try copy(from: path, to: to, ensureDestinationIsAbsent: true) } public func copy(_ from: AbsolutePath, to: AbsolutePath) async throws { @@ -602,7 +683,7 @@ public struct FileSystem: FileSysteming, Sendable { if !(try await exists(to.parentDirectory)) { try await makeDirectory(at: to.parentDirectory) } - try copyItem(from: from, to: to) + try copy(from: from, to: to, ensureDestinationIsAbsent: true) } public func runInTemporaryDirectory( @@ -648,11 +729,45 @@ public struct FileSystem: FileSysteming, Sendable { lastModificationDate: Date? ) async throws { logger?.debug("Setting file times at path \(path.pathString).") - try updateItemTimes( - of: path, - lastAccessDate: lastAccessDate, - lastModificationDate: lastModificationDate - ) + #if os(Windows) + let handle = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { wpath in + CreateFileW( + wpath, + DWORD(FILE_WRITE_ATTRIBUTES), + DWORD(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE), + nil, + DWORD(OPEN_EXISTING), + DWORD(FILE_ATTRIBUTE_NORMAL), + nil + ) + } + guard let handle, handle != INVALID_HANDLE_VALUE else { throw windowsError() } + defer { CloseHandle(handle) } + + let accessTime = lastAccessDate.map(windowsFileTime(from:)) + let modificationTime = lastModificationDate.map(windowsFileTime(from:)) + let success: Bool + if let accessTime, let modificationTime { + var accessTime = accessTime + var modificationTime = modificationTime + success = windowsSucceeded(SetFileTime(handle, nil, &accessTime, &modificationTime)) + } else if let accessTime { + var accessTime = accessTime + success = windowsSucceeded(SetFileTime(handle, nil, &accessTime, nil)) + } else if let modificationTime { + var modificationTime = modificationTime + success = windowsSucceeded(SetFileTime(handle, nil, nil, &modificationTime)) + } else { + return + } + guard windowsSucceeded(success) else { throw windowsError() } + #else + try Self.updateFileTimes( + path: path.pathString, + lastAccessDate: lastAccessDate, + lastModificationDate: lastModificationDate + ) + #endif } #if !os(Windows) @@ -855,15 +970,15 @@ extension FileSystem { private func createArchive(at sourceURL: URL, to destinationURL: URL, shouldKeepParent: Bool) throws { let sourcePath = try AbsolutePath(validating: sourceURL.path) let destinationPath = try AbsolutePath(validating: destinationURL.path) - guard try itemExists(at: sourcePath) else { + guard try exists(sourcePath, followSymlinks: true) else { throw CocoaError(.fileReadNoSuchFile, userInfo: [NSFilePathErrorKey: sourceURL.path]) } - guard try !itemExists(at: destinationPath, followSymlinks: false) else { + guard try !exists(destinationPath, followSymlinks: false) else { throw CocoaError(.fileWriteFileExists, userInfo: [NSFilePathErrorKey: destinationURL.path]) } let archive = try Archive(url: destinationURL, accessMode: .create) - if try itemExists(at: sourcePath, isDirectory: true) { + if try exists(sourcePath, as: .directory) { let baseURL = shouldKeepParent ? URL(fileURLWithPath: sourcePath.parentDirectory.pathString) : sourceURL @@ -886,7 +1001,7 @@ extension FileSystem { private func extractArchive(at sourceURL: URL, to destinationURL: URL) throws { let sourcePath = try AbsolutePath(validating: sourceURL.path) - guard try itemExists(at: sourcePath) else { + guard try exists(sourcePath, followSymlinks: true) else { throw CocoaError(.fileReadNoSuchFile, userInfo: [NSFilePathErrorKey: sourceURL.path]) } @@ -916,33 +1031,9 @@ private struct FileInfo { } extension FileSystem { - private func currentWorkingDirectoryPath() throws -> String { - #if os(Windows) - var buffer = [WCHAR](repeating: 0, count: Int(MAX_PATH) + 1) - var length = buffer.withUnsafeMutableBufferPointer { - GetCurrentDirectoryW(DWORD($0.count), $0.baseAddress) - } - guard length > 0 else { throw windowsError() } - if Int(length) >= buffer.count { - buffer = [WCHAR](repeating: 0, count: Int(length) + 1) - length = buffer.withUnsafeMutableBufferPointer { - GetCurrentDirectoryW(DWORD($0.count), $0.baseAddress) - } - guard length > 0, Int(length) < buffer.count else { throw windowsError() } - } - return buffer.withUnsafeBufferPointer { - String(decodingCString: $0.baseAddress!, as: UTF16.self) - } - #else - var buffer = [CChar](repeating: 0, count: Int(PATH_MAX)) - guard getcwd(&buffer, buffer.count) != nil else { throw posixError() } - return String(cString: buffer) - #endif - } - - private func directoryContents(at path: AbsolutePath) throws -> [AbsolutePath] { + private func contentsOfDirectory(at path: AbsolutePath) throws -> [AbsolutePath] { #if os(Windows) - guard try itemExists(at: path, isDirectory: true) else { + guard try exists(path, as: .directory) else { throw windowsError(DWORD(ERROR_PATH_NOT_FOUND)) } @@ -997,44 +1088,16 @@ extension FileSystem { #endif } - private func itemExists(at path: AbsolutePath) throws -> Bool { - try itemExists(at: path, followSymlinks: true) - } - - private func itemExists(at path: AbsolutePath, isDirectory: Bool) throws -> Bool { + private func exists(_ path: AbsolutePath, as kind: FileKind) throws -> Bool { guard let info = try fileInfo(at: path, followSymlinks: true) else { return false } - return info.kind == (isDirectory ? .directory : .file) + return info.kind == kind } - private func itemExists(at path: AbsolutePath, followSymlinks: Bool) throws -> Bool { + private func exists(_ path: AbsolutePath, followSymlinks: Bool) throws -> Bool { try fileInfo(at: path, followSymlinks: followSymlinks) != nil } - private func createEmptyFile(at path: AbsolutePath) throws { - #if os(Windows) - let handle = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { wpath in - CreateFileW( - wpath, - DWORD(GENERIC_WRITE), - DWORD(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE), - nil, - DWORD(CREATE_NEW), - DWORD(FILE_ATTRIBUTE_NORMAL), - nil - ) - } - guard let handle, handle != INVALID_HANDLE_VALUE else { throw windowsError() } - CloseHandle(handle) - #else - let descriptor = path.pathString.withCString { pathPointer in - open(pathPointer, O_WRONLY | O_CREAT | O_EXCL, mode_t(0o666)) - } - guard descriptor >= 0 else { throw posixError() } - _ = close(descriptor) - #endif - } - - private func removeItem(at path: AbsolutePath) throws { + private func remove(at path: AbsolutePath) throws { #if os(Windows) let attributes = windowsAttributes(atPath: path.pathString) guard attributes != INVALID_FILE_ATTRIBUTES else { return } @@ -1042,8 +1105,8 @@ extension FileSystem { let isDirectory = (attributes & DWORD(FILE_ATTRIBUTE_DIRECTORY)) != 0 let isReparsePoint = (attributes & DWORD(FILE_ATTRIBUTE_REPARSE_POINT)) != 0 if isDirectory, !isReparsePoint { - for child in try directoryContents(at: path) { - try removeItem(at: child) + for child in try contentsOfDirectory(at: path) { + try remove(at: child) } let success = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { RemoveDirectoryW($0) @@ -1064,8 +1127,8 @@ extension FileSystem { guard let info = try fileInfo(at: path, followSymlinks: false) else { return } switch info.kind { case .directory: - for child in try directoryContents(at: path) { - try removeItem(at: child) + for child in try contentsOfDirectory(at: path) { + try remove(at: child) } let result = path.pathString.withCString { rmdir($0) } guard result == 0 else { throw posixError() } @@ -1076,51 +1139,8 @@ extension FileSystem { #endif } - private func makeTemporaryDirectoryPath(prefix: String) throws -> AbsolutePath { - #if os(Windows) - var buffer = [WCHAR](repeating: 0, count: Int(MAX_PATH) + 1) - var length = buffer.withUnsafeMutableBufferPointer { - GetTempPathW(DWORD($0.count), $0.baseAddress) - } - guard length > 0 else { throw windowsError() } - if Int(length) >= buffer.count { - buffer = [WCHAR](repeating: 0, count: Int(length) + 1) - length = buffer.withUnsafeMutableBufferPointer { - GetTempPathW(DWORD($0.count), $0.baseAddress) - } - guard length > 0, Int(length) < buffer.count else { throw windowsError() } - } - let temporaryDirectory = try AbsolutePath( - validating: buffer.withUnsafeBufferPointer { - String(decodingCString: $0.baseAddress!, as: UTF16.self) - } - ) - let path = temporaryDirectory.appending(component: "\(prefix)-\(UUID().uuidString)") - try createDirectory(at: path, createIntermediates: true) - return path - #else - var systemTemporaryDirectory = NSTemporaryDirectory() - - #if os(macOS) - if systemTemporaryDirectory.starts(with: "/var/") { - systemTemporaryDirectory = "/private\(systemTemporaryDirectory)" - } - #endif - - let basePath = try AbsolutePath(validating: systemTemporaryDirectory) - var template = basePath.appending(component: "\(prefix)-XXXXXX").pathString.utf8CString - let createdPath = template.withUnsafeMutableBufferPointer { pointer -> String? in - guard let baseAddress = pointer.baseAddress else { return nil } - guard let pathPointer = mkdtemp(baseAddress) else { return nil } - return String(cString: pathPointer) - } - guard let createdPath else { throw posixError() } - return try AbsolutePath(validating: createdPath) - #endif - } - - private func moveItem(from: AbsolutePath, to: AbsolutePath) throws { - if try itemExists(at: to, followSymlinks: false) { + private func move(from: AbsolutePath, to: AbsolutePath, ensureDestinationIsAbsent: Bool) throws { + if ensureDestinationIsAbsent, try exists(to, followSymlinks: false) { throw fileExistsError(at: to) } @@ -1140,8 +1160,8 @@ extension FileSystem { guard result == 0 else { let error = errno if error == EXDEV { - try copyItem(from: from, to: to) - try removeItem(at: from) + try copy(from: from, to: to, ensureDestinationIsAbsent: false) + try remove(at: from) return } throw posixError(error) @@ -1149,7 +1169,7 @@ extension FileSystem { #endif } - private func createDirectory(at path: AbsolutePath, createIntermediates: Bool) throws { + private func makeDirectory(at path: AbsolutePath, createIntermediates: Bool) throws { #if os(Windows) if let existing = try fileInfo(at: path, followSymlinks: false) { guard existing.kind == .directory else { throw windowsError(DWORD(ERROR_ALREADY_EXISTS)) } @@ -1158,7 +1178,7 @@ extension FileSystem { if createIntermediates { let parent = path.parentDirectory if parent != path { - try createDirectory(at: parent, createIntermediates: true) + try makeDirectory(at: parent, createIntermediates: true) } } let success = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { wpath in @@ -1166,7 +1186,7 @@ extension FileSystem { } guard windowsSucceeded(success) else { let error = GetLastError() - if error == DWORD(ERROR_ALREADY_EXISTS), try itemExists(at: path, isDirectory: true) { + if error == DWORD(ERROR_ALREADY_EXISTS), try exists(path, as: .directory) { return } throw windowsError(error) @@ -1179,13 +1199,13 @@ extension FileSystem { if createIntermediates { let parent = path.parentDirectory if parent != path { - try createDirectory(at: parent, createIntermediates: true) + try makeDirectory(at: parent, createIntermediates: true) } } let result = path.pathString.withCString { mkdir($0, mode_t(0o755)) } guard result == 0 else { let error = errno - if error == EEXIST, try itemExists(at: path, isDirectory: true) { + if error == EEXIST, try exists(path, as: .directory) { return } throw posixError(error) @@ -1193,8 +1213,8 @@ extension FileSystem { #endif } - private func copyItem(from: AbsolutePath, to: AbsolutePath) throws { - if try itemExists(at: to, followSymlinks: false) { + private func copy(from: AbsolutePath, to: AbsolutePath, ensureDestinationIsAbsent: Bool) throws { + if ensureDestinationIsAbsent, try exists(to, followSymlinks: false) { throw fileExistsError(at: to) } @@ -1204,9 +1224,9 @@ extension FileSystem { switch info.kind { case .directory: - try createDirectory(at: to, createIntermediates: false) - for child in try directoryContents(at: from) { - try copyItem(from: child, to: to.appending(component: child.basename)) + try makeDirectory(at: to, createIntermediates: false) + for child in try contentsOfDirectory(at: from) { + try copy(from: child, to: to.appending(component: child.basename), ensureDestinationIsAbsent: true) } case .symbolicLink: let destination = try readSymbolicLinkDestination(at: from) @@ -1230,52 +1250,6 @@ extension FileSystem { return FileMetadata(size: info.size, lastModificationDate: info.modificationDate) } - private func updateItemTimes( - of path: AbsolutePath, - lastAccessDate: Date?, - lastModificationDate: Date? - ) throws { - #if os(Windows) - let handle = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { wpath in - CreateFileW( - wpath, - DWORD(FILE_WRITE_ATTRIBUTES), - DWORD(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE), - nil, - DWORD(OPEN_EXISTING), - DWORD(FILE_ATTRIBUTE_NORMAL), - nil - ) - } - guard let handle, handle != INVALID_HANDLE_VALUE else { throw windowsError() } - defer { CloseHandle(handle) } - - let accessTime = lastAccessDate.map(windowsFileTime(from:)) - let modificationTime = lastModificationDate.map(windowsFileTime(from:)) - let success: Bool - if let accessTime, let modificationTime { - var accessTime = accessTime - var modificationTime = modificationTime - success = windowsSucceeded(SetFileTime(handle, nil, &accessTime, &modificationTime)) - } else if let accessTime { - var accessTime = accessTime - success = windowsSucceeded(SetFileTime(handle, nil, &accessTime, nil)) - } else if let modificationTime { - var modificationTime = modificationTime - success = windowsSucceeded(SetFileTime(handle, nil, nil, &modificationTime)) - } else { - return - } - guard windowsSucceeded(success) else { throw windowsError() } - #else - try Self.updateFileTimes( - path: path.pathString, - lastAccessDate: lastAccessDate, - lastModificationDate: lastModificationDate - ) - #endif - } - private func makeSymbolicLink(fromPathString: String, toPathString: String) throws { #if os(Windows) var flags = DWORD(0x2) @@ -1354,7 +1328,7 @@ extension FileSystem { private func descendantRelativePaths(of directory: AbsolutePath, prefix: String) throws -> [String] { var descendants: [String] = [] - for child in try directoryContents(at: directory) { + for child in try contentsOfDirectory(at: directory) { let relativePath = prefix.isEmpty ? child.basename : "\(prefix)/\(child.basename)" descendants.append(relativePath) From 3c6c86d53720163f5513b50efd147f0fcd8186b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Pi=C3=B1era?= Date: Tue, 17 Mar 2026 16:05:22 +0100 Subject: [PATCH 09/19] test: migrate to Swift Testing and harden coverage --- Tests/FileSystemTests/FileSystemTests.swift | 412 +++++++++++++----- .../FileSystemWindowsTests.swift | 77 +++- Tests/GlobTests/PatternTests.swift | 96 ++-- .../TestHelpers/XCTAssertMatches.swift | 33 -- .../TestHelpers/XCTExpectFailure.swift | 5 - 5 files changed, 426 insertions(+), 197 deletions(-) delete mode 100644 Tests/GlobTests/TestHelpers/XCTAssertMatches.swift delete mode 100644 Tests/GlobTests/TestHelpers/XCTExpectFailure.swift diff --git a/Tests/FileSystemTests/FileSystemTests.swift b/Tests/FileSystemTests/FileSystemTests.swift index dd87aef..0190218 100644 --- a/Tests/FileSystemTests/FileSystemTests.swift +++ b/Tests/FileSystemTests/FileSystemTests.swift @@ -1,5 +1,7 @@ +import Foundation import Path -import XCTest +import Testing + @testable import FileSystem private struct TestError: Error, Equatable {} @@ -7,32 +9,24 @@ private struct TestError: Error, Equatable {} // FileSystem tests are currently skipped on Windows due to hanging issues with async file operations. // The Windows build passes, so the library is usable. Tests can be enabled gradually as issues are resolved. #if !os(Windows) - final class FileSystemTests: XCTestCase, @unchecked Sendable { - var subject: FileSystem! - - override func setUp() async throws { - try await super.setUp() - subject = FileSystem() - } - - override func tearDown() async throws { - subject = nil - try await super.tearDown() - } + struct FileSystemTests { + let subject = FileSystem() + @Test func test_createTemporaryDirectory_returnsAValidDirectory() async throws { // Given let temporaryDirectory = try await subject.makeTemporaryDirectory(prefix: "FileSystem") // When let exists = try await subject.exists(temporaryDirectory) - XCTAssertTrue(exists) + #expect(exists) let firstExists = try await subject.exists(temporaryDirectory, isDirectory: true) - XCTAssertTrue(firstExists) + #expect(firstExists) let secondExists = try await subject.exists(temporaryDirectory, isDirectory: false) - XCTAssertFalse(secondExists) + #expect(!secondExists) } + @Test func test_runInTemporaryDirectory_removesTheDirectoryAfterSuccessfulCompletion() async throws { // Given/When let temporaryDirectory = try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in @@ -42,9 +36,10 @@ private struct TestError: Error, Equatable {} // Then let exists = try await subject.exists(temporaryDirectory) - XCTAssertFalse(exists) + #expect(!exists) } + @Test func test_runInTemporaryDirectory_rethrowsErrors() async throws { // Given/When var caughtError: Error? @@ -57,18 +52,20 @@ private struct TestError: Error, Equatable {} } // Then - XCTAssertEqual(caughtError as? TestError, TestError()) + #expect((caughtError as? TestError) == TestError()) } + @Test func test_currentWorkingDirectory() async throws { // When let got = try await subject.currentWorkingDirectory() // Then let isDirectory = try await subject.exists(got, isDirectory: true) - XCTAssertTrue(isDirectory) + #expect(isDirectory) } + @Test func test_move_when_fromFileExistsAndToPathsParentDirectoryExists() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -81,10 +78,29 @@ private struct TestError: Error, Equatable {} // Then let exists = try await subject.exists(toFilePath) - XCTAssertTrue(exists) + #expect(exists) + } + } + + @Test + func test_move_createsTargetParentDirectoriesByDefault() async throws { + try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in + // Given + let fromFilePath = temporaryDirectory.appending(component: "from") + let toFilePath = temporaryDirectory.appending(components: ["nested", "to"]) + try await subject.writeText("content", at: fromFilePath) + + // When + try await subject.move(from: fromFilePath, to: toFilePath) + + // Then + #expect(!(try await subject.exists(fromFilePath))) + #expect(try await subject.exists(toFilePath)) + #expect(try await subject.readTextFile(at: toFilePath) == "content") } } + @Test func test_move_throwsAMoveNotFoundError_when_fromFileDoesntExist() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -100,10 +116,11 @@ private struct TestError: Error, Equatable {} } // Then - XCTAssertEqual(_error, FileSystemError.moveNotFound(from: fromFilePath, to: toFilePath)) + #expect(_error == FileSystemError.moveNotFound(from: fromFilePath, to: toFilePath)) } } + @Test func test_makeDirectory_createsTheDirectory() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -114,10 +131,11 @@ private struct TestError: Error, Equatable {} // Then let exists = try await subject.exists(directoryPath) - XCTAssertTrue(exists) + #expect(exists) } } + @Test func test_makeDirectory_createsTheParentDirectories() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -128,10 +146,11 @@ private struct TestError: Error, Equatable {} // Then let exists = try await subject.exists(directoryPath) - XCTAssertTrue(exists) + #expect(exists) } } + @Test func test_makeDirectory_throwsAnError_when_parentDirectoryDoesntExist() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -146,10 +165,11 @@ private struct TestError: Error, Equatable {} } // Then - XCTAssertEqual(_error, FileSystemError.makeDirectoryAbsentParent(directoryPath)) + #expect(_error == FileSystemError.makeDirectoryAbsentParent(directoryPath)) } } + @Test func test_writeTextFile_and_readTextFile_returnsTheContent() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -160,10 +180,11 @@ private struct TestError: Error, Equatable {} let got = try await subject.readTextFile(at: filePath) // Then - XCTAssertEqual(got, "test") + #expect(got == "test") } } + @Test func test_writeTextFile_and_readTextFile_returnsTheContent_when_whenOverwritingFile() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -175,10 +196,41 @@ private struct TestError: Error, Equatable {} let got = try await subject.readTextFile(at: filePath) // Then - XCTAssertEqual(got, "test") + #expect(got == "test") + } + } + + @Test + func test_readFile_returnsTheRawContents() async throws { + try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in + // Given + let filePath = temporaryDirectory.appending(component: "file") + let expected = Data([0x00, 0xFF, 0x10, 0x42]) + try expected.write(to: URL(fileURLWithPath: filePath.pathString)) + + // When + let got = try await subject.readFile(at: filePath) + + // Then + #expect(got == expected) + } + } + + @Test + func test_readTextFile_throwsWhenEncodingDoesNotMatchTheFileContent() async throws { + try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in + // Given + let filePath = temporaryDirectory.appending(component: "file") + try await subject.writeText("é", at: filePath, encoding: .utf8) + + // When/Then + await #expect(throws: FileSystemError.readInvalidEncoding(.ascii, path: filePath)) { + try await subject.readTextFile(at: filePath, encoding: .ascii) + } } } + @Test func test_writeAsJSON_and_readJSONFile_returnsTheContent() async throws { struct CodableStruct: Codable, Equatable { let name: String } @@ -192,10 +244,11 @@ private struct TestError: Error, Equatable {} let got: CodableStruct = try await subject.readJSONFile(at: filePath) // Then - XCTAssertEqual(got, item) + #expect(got == item) } } + @Test func test_writeAsJSON_and_readJSONFile_returnsTheContent_when_whenOverwritingFile() async throws { struct CodableStruct: Codable, Equatable { let name: String } @@ -210,10 +263,11 @@ private struct TestError: Error, Equatable {} let got: CodableStruct = try await subject.readJSONFile(at: filePath) // Then - XCTAssertEqual(got, item) + #expect(got == item) } } + @Test func test_writeAsPlist_and_readPlistFile_returnsTheContent() async throws { struct CodableStruct: Codable, Equatable { let name: String } @@ -227,10 +281,11 @@ private struct TestError: Error, Equatable {} let got: CodableStruct = try await subject.readPlistFile(at: filePath) // Then - XCTAssertEqual(got, item) + #expect(got == item) } } + @Test func test_writeAsPlist_and_readPlistFile_returnsTheContent_when_overridingFile() async throws { struct CodableStruct: Codable, Equatable { let name: String } @@ -245,10 +300,11 @@ private struct TestError: Error, Equatable {} let got: CodableStruct = try await subject.readPlistFile(at: filePath) // Then - XCTAssertEqual(got, item) + #expect(got == item) } } + @Test func test_fileSizeInBytes_returnsTheFileSize_when_itExists() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -259,10 +315,11 @@ private struct TestError: Error, Equatable {} let size = try await subject.fileSizeInBytes(at: path) // Then - XCTAssertEqual(size, 5) + #expect(size == 5) } } + @Test func test_fileSizeInBytes_returnsNil_when_theFileDoesntExist() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -272,10 +329,11 @@ private struct TestError: Error, Equatable {} let size = try await subject.fileSizeInBytes(at: path) // Then - XCTAssertNil(size) + #expect(size == nil) } } + @Test func test_fileMetadata_when_fileAbsent() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -285,10 +343,11 @@ private struct TestError: Error, Equatable {} let modificationDate = try await subject.fileMetadata(at: path) // Then - XCTAssertNil(modificationDate) + #expect(modificationDate == nil) } } + @Test func test_fileMetadata_when_filePresent() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -299,10 +358,11 @@ private struct TestError: Error, Equatable {} let metadata = try await subject.fileMetadata(at: path) // Then - XCTAssertNotNil(metadata?.lastModificationDate) + #expect(metadata?.lastModificationDate != nil) } } + @Test func test_setFileTimes_modificationDate() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -315,14 +375,47 @@ private struct TestError: Error, Equatable {} // Then let metadata = try await subject.fileMetadata(at: path) - XCTAssertEqual( - metadata?.lastModificationDate.timeIntervalSince1970 ?? 0, - pastDate.timeIntervalSince1970, - accuracy: 1.0 - ) + #expect(abs((metadata?.lastModificationDate.timeIntervalSince1970 ?? 0) - pastDate.timeIntervalSince1970) <= 1.0) + } + } + + @Test + func test_setFileTimes_accessDate_preservesModificationDate() async throws { + try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in + // Given + let path = temporaryDirectory.appending(component: "file") + try await subject.touch(path) + let pastDate = Date(timeIntervalSince1970: 1_000_000) + try await subject.setFileTimes(of: path, lastAccessDate: nil, lastModificationDate: pastDate) + + // When + try await subject.setFileTimes(of: path, lastAccessDate: Date(), lastModificationDate: nil) + + // Then + let metadata = try await subject.fileMetadata(at: path) + #expect(abs((metadata?.lastModificationDate.timeIntervalSince1970 ?? 0) - pastDate.timeIntervalSince1970) <= 1.0) } } + @Test + func test_touch_updatesModificationDate_whenFileAlreadyExists() async throws { + try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in + // Given + let path = temporaryDirectory.appending(component: "file") + try await subject.touch(path) + let pastDate = Date(timeIntervalSince1970: 1_000_000) + try await subject.setFileTimes(of: path, lastAccessDate: nil, lastModificationDate: pastDate) + + // When + try await subject.touch(path) + + // Then + let metadata = try await subject.fileMetadata(at: path) + #expect((metadata?.lastModificationDate.timeIntervalSince1970 ?? 0) > pastDate.timeIntervalSince1970 + 10) + } + } + + @Test func test_replace_replaces_when_replacingPathIsADirectory_and_targetDirectoryIsAbsent() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -337,10 +430,11 @@ private struct TestError: Error, Equatable {} // Then let exists = try await subject.exists(replacedPath.appending(component: "file")) - XCTAssertTrue(exists) + #expect(exists) } } + @Test func test_replace_replaces_when_replacingPathIsADirectory_and_targetDirectoryIsPresent() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -356,10 +450,11 @@ private struct TestError: Error, Equatable {} // Then let exists = try await subject.exists(replacedPath.appending(component: "file")) - XCTAssertTrue(exists) + #expect(exists) } } + @Test func test_replace_replaces_when_replacingPathDoesntExist() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -379,13 +474,14 @@ private struct TestError: Error, Equatable {} } // Then - XCTAssertEqual( - _error, - FileSystemError.replacingItemAbsent(replacingPath: replacingFilePath, replacedPath: replacedFilePath) - ) + #expect(_error == FileSystemError.replacingItemAbsent( + replacingPath: replacingFilePath, + replacedPath: replacedFilePath + )) } } + @Test func test_replace_createsTheReplacedPathParentDirectoryIfAbsent() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -401,10 +497,11 @@ private struct TestError: Error, Equatable {} // Then let exists = try await subject.exists(replacedFilePath) - XCTAssertTrue(exists) + #expect(exists) } } + @Test func test_copy_copiesASourceItemToATargetPath() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -417,10 +514,11 @@ private struct TestError: Error, Equatable {} // Then let exists = try await subject.exists(toPath) - XCTAssertTrue(exists) + #expect(exists) } } + @Test func test_copy_createsTargetParentDirectoriesIfNeeded() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -433,10 +531,32 @@ private struct TestError: Error, Equatable {} // Then let exists = try await subject.exists(toPath) - XCTAssertTrue(exists) + #expect(exists) + } + } + + @Test + func test_copy_copiesDirectoriesRecursively() async throws { + try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in + // Given + let sourceDirectory = temporaryDirectory.appending(component: "source") + let nestedDirectory = sourceDirectory.appending(component: "nested") + let sourceFile = nestedDirectory.appending(component: "file.txt") + let destinationDirectory = temporaryDirectory.appending(component: "destination") + try await subject.makeDirectory(at: nestedDirectory) + try await subject.writeText("content", at: sourceFile) + + // When + try await subject.copy(sourceDirectory, to: destinationDirectory) + + // Then + let copiedFile = destinationDirectory.appending(components: ["nested", "file.txt"]) + #expect(try await subject.exists(copiedFile)) + #expect(try await subject.readTextFile(at: copiedFile) == "content") } } + @Test func test_copy_errorsIfTheSourceItemDoesntExist() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -452,15 +572,16 @@ private struct TestError: Error, Equatable {} } // Then - XCTAssertEqual(_error, FileSystemError.copiedItemAbsent(copiedPath: fromPath, intoPath: toPath)) + #expect(_error == FileSystemError.copiedItemAbsent(copiedPath: fromPath, intoPath: toPath)) } } + @Test func test_locateTraversingUp_whenAnItemIsFound() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given let fileToLookUp = temporaryDirectory.appending(component: "FileSystem.swift") - try await self.subject.touch(fileToLookUp) + try await subject.touch(fileToLookUp) let veryNestedDirectory = temporaryDirectory.appending(components: ["first", "second", "third"]) // When @@ -470,10 +591,11 @@ private struct TestError: Error, Equatable {} ) // Then - XCTAssertEqual(got, fileToLookUp) + #expect(got == fileToLookUp) } } + @Test func test_locateTraversingUp_whenAnItemIsNotFound() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -486,12 +608,13 @@ private struct TestError: Error, Equatable {} ) // Then - XCTAssertNil(got) + #expect(got == nil) } } // Symbolic link tests are skipped on Windows because symlinks require elevated permissions #if !os(Windows) + @Test func test_createSymbolicLink() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -504,10 +627,11 @@ private struct TestError: Error, Equatable {} let got = try await subject.resolveSymbolicLink(symbolicLinkPath) // Then - XCTAssertEqual(got, filePath) + #expect(got == filePath) } } + @Test func test_createSymbolicLink_whenTheSymbolicLinkDoesntExist() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -524,10 +648,11 @@ private struct TestError: Error, Equatable {} } // Then - XCTAssertEqual(_error, FileSystemError.absentSymbolicLink(symbolicLinkPath)) + #expect(_error == FileSystemError.absentSymbolicLink(symbolicLinkPath)) } } + @Test func test_resolveSymbolicLink_whenTheDestinationIsRelative() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -540,10 +665,11 @@ private struct TestError: Error, Equatable {} let got = try await subject.resolveSymbolicLink(symbolicPath) // Then - XCTAssertEqual(got, destinationPath) + #expect(got == destinationPath) } } + @Test func test_resolveSymbolicLink_whenThePathIsNotASymbolicLink() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -554,12 +680,56 @@ private struct TestError: Error, Equatable {} let got = try await subject.resolveSymbolicLink(directoryPath) // Then - XCTAssertEqual(got, directoryPath) + #expect(got == directoryPath) + } + } + + @Test + func test_copy_preservesSymbolicLinks() async throws { + try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in + // Given + let destinationPath = temporaryDirectory.appending(component: "destination") + let symbolicLinkPath = temporaryDirectory.appending(component: "symbolic") + let copiedLinkPath = temporaryDirectory.appending(component: "copied") + try await subject.touch(destinationPath) + try await subject.createSymbolicLink(from: symbolicLinkPath, to: destinationPath) + + // When + try await subject.copy(symbolicLinkPath, to: copiedLinkPath) + let got = try await subject.resolveSymbolicLink(copiedLinkPath) + + // Then + #expect(got == destinationPath) + } + } + + @Test + func test_remove_directorySymbolicLink_doesNotRemoveDestination() async throws { + try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in + // Given + let destinationDirectory = temporaryDirectory.appending(component: "destination") + let destinationFile = destinationDirectory.appending(component: "file") + let symbolicLinkPath = temporaryDirectory.appending(component: "symbolic") + try await subject.makeDirectory(at: destinationDirectory) + try await subject.touch(destinationFile) + try await subject.createSymbolicLink(from: symbolicLinkPath, to: destinationDirectory) + + // When + try await subject.remove(symbolicLinkPath) + + // Then + let symbolicLinkExists = try await subject.exists(symbolicLinkPath) + let destinationDirectoryExists = try await subject.exists(destinationDirectory, isDirectory: true) + let destinationFileExists = try await subject.exists(destinationFile) + #expect(!symbolicLinkExists) + #expect(destinationDirectoryExists) + #expect(destinationFileExists) } } #endif #if !os(Windows) + @Test func test_zipping() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -575,11 +745,35 @@ private struct TestError: Error, Equatable {} // Then let exists = try await subject.exists(unzippedPath.appending(component: "file")) - XCTAssertTrue(exists) + #expect(exists) + } + } + + @Test + func test_zippingDirectoryContent_doesNotKeepTheParentDirectory() async throws { + try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in + // Given + let sourceDirectory = temporaryDirectory.appending(component: "source") + let nestedDirectory = sourceDirectory.appending(component: "nested") + let sourceFile = nestedDirectory.appending(component: "file.txt") + let zipPath = temporaryDirectory.appending(component: "directory.zip") + let unzippedPath = temporaryDirectory.appending(component: "unzipped") + try await subject.makeDirectory(at: nestedDirectory) + try await subject.makeDirectory(at: unzippedPath) + try await subject.writeText("content", at: sourceFile) + + // When + try await subject.zipFileOrDirectoryContent(at: sourceDirectory, to: zipPath) + try await subject.unzip(zipPath, to: unzippedPath) + + // Then + #expect(try await subject.exists(unzippedPath.appending(components: ["nested", "file.txt"]))) + #expect(!(try await subject.exists(unzippedPath.appending(component: "source")))) } } #endif + @Test func test_glob_component_wildcard() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -598,10 +792,11 @@ private struct TestError: Error, Equatable {} .sorted() // Then - XCTAssertEqual(got, [firstSourceFile]) + #expect(got == [firstSourceFile]) } } + @Test func test_glob_nested_component_wildcard() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -618,13 +813,14 @@ private struct TestError: Error, Equatable {} .sorted() // Then - XCTAssertEqual(got, [firstSourceFile]) + #expect(got == [firstSourceFile]) } } // The following behavior works correctly only on Apple environments due to discrepancies in the `Foundation` // implementation. #if !os(Linux) + @Test func test_glob_when_recursive_glob_with_file_being_in_the_base_directory() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -645,11 +841,12 @@ private struct TestError: Error, Equatable {} .sorted() // Then - XCTAssertEqual(got, [firstSourceFile]) + #expect(got == [firstSourceFile]) } } #endif + @Test func test_glob_with_nested_directories() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -673,10 +870,11 @@ private struct TestError: Error, Equatable {} .sorted() // Then - XCTAssertEqual(got, [firstSourceFile, secondSourceFile, topFile]) + #expect(got == [firstSourceFile, secondSourceFile, topFile]) } } + @Test func test_glob_with_file_in_a_nested_directory_with_a_component_wildcard() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -695,10 +893,11 @@ private struct TestError: Error, Equatable {} .sorted() // Then - XCTAssertEqual(got, [firstSourceFile]) + #expect(got == [firstSourceFile]) } } + @Test func test_glob_with_file_and_only_a_directory_wildcard() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -714,10 +913,11 @@ private struct TestError: Error, Equatable {} .sorted() // Then - XCTAssertEqual(got, [firstSourceFile]) + #expect(got == [firstSourceFile]) } } + @Test func test_glob_with_file_with_a_space_and_only_a_directory_wildcard() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -733,10 +933,11 @@ private struct TestError: Error, Equatable {} .sorted() // Then - XCTAssertEqual(got, [sourceFile]) + #expect(got == [sourceFile]) } } + @Test func test_glob_with_file_with_a_special_character_and_only_a_directory_wildcard() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -752,10 +953,11 @@ private struct TestError: Error, Equatable {} .sorted() // Then - XCTAssertEqual(got, [sourceFile]) + #expect(got == [sourceFile]) } } + @Test func test_glob_with_hash_character_in_directory_name() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given: A directory with a # character in its name (common in Azure AD usernames) @@ -773,10 +975,11 @@ private struct TestError: Error, Equatable {} .sorted() // Then - XCTAssertEqual(got, [sourceFile]) + #expect(got == [sourceFile]) } } + @Test func test_glob_with_path_wildcard_and_a_constant_file_name() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -795,10 +998,11 @@ private struct TestError: Error, Equatable {} .sorted() // Then - XCTAssertEqual(got, [sourceFile]) + #expect(got == [sourceFile]) } } + @Test func test_glob_with_file_in_a_directory_with_a_space() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -816,10 +1020,11 @@ private struct TestError: Error, Equatable {} .sorted() // Then - XCTAssertEqual(got, [sourceFile]) + #expect(got == [sourceFile]) } } + @Test func test_glob_with_file_extension_wildcard() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -837,10 +1042,11 @@ private struct TestError: Error, Equatable {} .sorted() // Then - XCTAssertEqual(got, [sourceFile]) + #expect(got == [sourceFile]) } } + @Test func test_glob_with_hidden_file_and_extension_wildcard() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -858,10 +1064,11 @@ private struct TestError: Error, Equatable {} .sorted() // Then - XCTAssertEqual(got, [sourceFile]) + #expect(got == [sourceFile]) } } + @Test func test_glob_with_constant_file() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -879,10 +1086,11 @@ private struct TestError: Error, Equatable {} .sorted() // Then - XCTAssertEqual(got, [sourceFile]) + #expect(got == [sourceFile]) } } + @Test func test_glob_with_path_wildcard() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -900,10 +1108,11 @@ private struct TestError: Error, Equatable {} .sorted() // Then - XCTAssertEqual(got, [sourceFile]) + #expect(got == [sourceFile]) } } + @Test func test_glob_with_nested_files_and_only_a_directory_wildcard() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -922,10 +1131,11 @@ private struct TestError: Error, Equatable {} .sorted() // Then - XCTAssertEqual(got, [firstDirectory, sourceFile, secondDirectory]) + #expect(got == [firstDirectory, sourceFile, secondDirectory]) } } + @Test func test_glob_with_nested_files_and_only_a_directory_wildcard_when_ds_store_is_present() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -946,10 +1156,11 @@ private struct TestError: Error, Equatable {} .sorted() // Then - XCTAssertEqual(got, [firstDirectory, sourceFile, secondDirectory]) + #expect(got == [firstDirectory, sourceFile, secondDirectory]) } } + @Test func test_glob_with_nested_files_and_only_a_directory_wildcard_when_git_keep_is_present() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -970,12 +1181,13 @@ private struct TestError: Error, Equatable {} .sorted() // Then - XCTAssertEqual(got, [firstDirectory, sourceFile, secondDirectory]) + #expect(got == [firstDirectory, sourceFile, secondDirectory]) } } // Glob tests involving symlinks are skipped on Windows because symlinks require elevated permissions #if !os(Windows) + @Test func test_glob_with_symlink_and_only_a_directory_wildcard() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -998,10 +1210,11 @@ private struct TestError: Error, Equatable {} .sorted() // Then - XCTAssertEqual(got, [symlinkSourceFilePath]) + #expect(got == [symlinkSourceFilePath]) } } + @Test func test_glob_with_symlink_as_base_url() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -1022,10 +1235,11 @@ private struct TestError: Error, Equatable {} .sorted() // Then - XCTAssertEqual(got, [symlinkSourceFilePath]) + #expect(got == [symlinkSourceFilePath]) } } + @Test func test_glob_with_relative_symlink() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -1055,11 +1269,12 @@ private struct TestError: Error, Equatable {} .sorted() // Then - XCTAssertEqual(got.count, 1) - XCTAssertEqual(got.map(\.basename), [versionPath.basename]) + #expect(got.count == 1) + #expect(got.map(\.basename) == [versionPath.basename]) } } + @Test func test_glob_with_relative_directory_symlink() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -1086,12 +1301,13 @@ private struct TestError: Error, Equatable {} .sorted() // Then - XCTAssertEqual(got.count, 1) - XCTAssertEqual(got.map(\.basename), [myStructPath.basename]) + #expect(got.count == 1) + #expect(got.map(\.basename) == [myStructPath.basename]) } } #endif + @Test func test_glob_with_double_directory_wildcard() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -1119,10 +1335,11 @@ private struct TestError: Error, Equatable {} .sorted() // Then - XCTAssertEqual(got, [fourthSourceFile, thirdSourceFile]) + #expect(got == [fourthSourceFile, thirdSourceFile]) } } + @Test func test_glob_with_extension_group() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -1139,16 +1356,11 @@ private struct TestError: Error, Equatable {} .sorted() // Then - XCTAssertEqual( - got, - [ - cppSourceFile, - swiftSourceFile, - ] - ) + #expect(got == [cppSourceFile, swiftSourceFile]) } } + @Test func test_remove_file() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -1160,10 +1372,11 @@ private struct TestError: Error, Equatable {} // Then let exists = try await subject.exists(file) - XCTAssertFalse(exists) + #expect(!exists) } } + @Test func test_remove_non_existing_file() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -1174,6 +1387,7 @@ private struct TestError: Error, Equatable {} } } + @Test func test_remove_directory_with_files() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -1190,12 +1404,13 @@ private struct TestError: Error, Equatable {} let directoryExists = try await subject.exists(directory) let nestedDirectoryExists = try await subject.exists(nestedDirectory) let fileExists = try await subject.exists(file) - XCTAssertFalse(directoryExists) - XCTAssertFalse(nestedDirectoryExists) - XCTAssertFalse(fileExists) + #expect(!directoryExists) + #expect(!nestedDirectoryExists) + #expect(!fileExists) } } + @Test func test_get_contents_of_directory() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -1213,10 +1428,11 @@ private struct TestError: Error, Equatable {} // Then let fileNames = contents.map(\.basename) - XCTAssertEqual(fileNames.sorted(), ["foo", "nested", "readme.md"]) + #expect(fileNames.sorted() == ["foo", "nested", "readme.md"]) } } + @Test func test_touch_createsFileVisibleToFoundation() async throws { try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in // Given @@ -1226,16 +1442,14 @@ private struct TestError: Error, Equatable {} try await subject.touch(filePath) // Then: The file should be immediately visible to Foundation APIs - XCTAssertTrue( + #expect( FileManager.default.fileExists(atPath: filePath.pathString), "File created by touch should be visible to FileManager.fileExists" ) // And: Foundation's FileHandle should be able to open it for writing - XCTAssertNoThrow( - try FileHandle(forWritingTo: URL(fileURLWithPath: filePath.pathString)), - "File created by touch should be openable by Foundation's FileHandle" - ) + let fileHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: filePath.pathString)) + try fileHandle.close() } } } diff --git a/Tests/FileSystemTests/FileSystemWindowsTests.swift b/Tests/FileSystemTests/FileSystemWindowsTests.swift index 8cb4c3c..2a9edb1 100644 --- a/Tests/FileSystemTests/FileSystemWindowsTests.swift +++ b/Tests/FileSystemTests/FileSystemWindowsTests.swift @@ -1,51 +1,88 @@ #if os(Windows) import Path - import XCTest + import Testing @testable import FileSystem - final class FileSystemWindowsTests: XCTestCase, @unchecked Sendable { - var subject: FileSystem! - - override func setUp() async throws { - try await super.setUp() - subject = FileSystem() - } - - override func tearDown() async throws { - subject = nil - try await super.tearDown() - } + struct FileSystemWindowsTests { + let subject = FileSystem() + @Test func test_exists_returnsTrueForDirectoryAndFalseForFileFlag() async throws { let temporaryDirectory = try await subject.makeTemporaryDirectory(prefix: "FileSystem") let exists = try await subject.exists(temporaryDirectory) - XCTAssertTrue(exists) + #expect(exists) let isDirectory = try await subject.exists(temporaryDirectory, isDirectory: true) - XCTAssertTrue(isDirectory) + #expect(isDirectory) let isFile = try await subject.exists(temporaryDirectory, isDirectory: false) - XCTAssertFalse(isFile) + #expect(!isFile) } + @Test func test_exists_returnsTrueForFile() async throws { let temporaryDirectory = try await subject.makeTemporaryDirectory(prefix: "FileSystem") let file = temporaryDirectory.appending(component: "file.txt") try await subject.touch(file) let exists = try await subject.exists(file) - XCTAssertTrue(exists) + #expect(exists) let isFile = try await subject.exists(file, isDirectory: false) - XCTAssertTrue(isFile) + #expect(isFile) let isDirectory = try await subject.exists(file, isDirectory: true) - XCTAssertFalse(isDirectory) + #expect(!isDirectory) } + @Test func test_exists_returnsFalseForMissingPath() async throws { let temporaryDirectory = try await subject.makeTemporaryDirectory(prefix: "FileSystem") let missing = temporaryDirectory.appending(component: "missing") let exists = try await subject.exists(missing) - XCTAssertFalse(exists) + #expect(!exists) + } + + @Test + func test_makeDirectory_touch_and_contentsOfDirectory() async throws { + let temporaryDirectory = try await subject.makeTemporaryDirectory(prefix: "FileSystem") + let directory = temporaryDirectory.appending(component: "directory") + let file = directory.appending(component: "file.txt") + + try await subject.makeDirectory(at: directory) + try await subject.touch(file) + + let contents = try await subject.contentsOfDirectory(directory) + + #expect(contents.map(\.basename) == ["file.txt"]) + } + + @Test + func test_move_movesAFile() async throws { + let temporaryDirectory = try await subject.makeTemporaryDirectory(prefix: "FileSystem") + let source = temporaryDirectory.appending(component: "source.txt") + let destination = temporaryDirectory.appending(component: "destination.txt") + try await subject.touch(source) + + try await subject.move(from: source, to: destination) + + let sourceExists = try await subject.exists(source) + let destinationExists = try await subject.exists(destination) + #expect(!sourceExists) + #expect(destinationExists) + } + + @Test + func test_glob_returnsMatches() async throws { + let temporaryDirectory = try await subject.makeTemporaryDirectory(prefix: "FileSystem") + let sourceDirectory = temporaryDirectory.appending(component: "Sources") + let file = sourceDirectory.appending(component: "File.swift") + try await subject.makeDirectory(at: sourceDirectory) + try await subject.touch(file) + + let got = try await subject.glob(directory: temporaryDirectory, include: ["**/*.swift"]) + .collect() + .sorted() + + #expect(got == [file]) } } #endif diff --git a/Tests/GlobTests/PatternTests.swift b/Tests/GlobTests/PatternTests.swift index a58ff1d..a68f48a 100644 --- a/Tests/GlobTests/PatternTests.swift +++ b/Tests/GlobTests/PatternTests.swift @@ -1,57 +1,66 @@ -import XCTest +import Testing @testable import Glob -final class PatternTests: XCTestCase { +struct PatternTests { + @Test func test_pathWildcard_matchesSingleNestedFolders() throws { - try XCTAssertMatches("Target/AutoMockable.generated.swift", pattern: "**/*.generated.swift") + #expect(try Pattern("**/*.generated.swift").match("Target/AutoMockable.generated.swift")) } + @Test func test_pathWildcard_with_constant_component() throws { - try XCTAssertMatches("file.swift", pattern: "**/file.swift") + #expect(try Pattern("**/file.swift").match("file.swift")) } + @Test func test_pathWildcard_matchesDirectFile() throws { - try XCTAssertMatches("AutoMockable.generated.swift", pattern: "**/*.generated.swift") + #expect(try Pattern("**/*.generated.swift").match("AutoMockable.generated.swift")) } + @Test func test_pathWildcard_does_not_match() throws { - try XCTAssertDoesNotMatch("AutoMockable.non-generated.swift", pattern: "**/*.generated.swift") + #expect(!(try Pattern("**/*.generated.swift").match("AutoMockable.non-generated.swift"))) } + @Test func test_double_pathWildcard_matchesDirectFileInNestedDirectory() throws { - try XCTAssertMatches("Target/Pivot/AutoMockable.generated.swift", pattern: "**/Pivot/**/*.generated.swift") + #expect(try Pattern("**/Pivot/**/*.generated.swift").match("Target/Pivot/AutoMockable.generated.swift")) } + @Test func test_double_pathWildcard_does_not_match_when_pivot_does_not_match() throws { - try XCTAssertDoesNotMatch( - "Target/NonMatchingPivot/AutoMockable.generated.swift", - pattern: "**/Pivot/**/*.generated.swift" - ) + #expect(!(try Pattern("**/Pivot/**/*.generated.swift").match("Target/NonMatchingPivot/AutoMockable.generated.swift"))) } + @Test func test_double_pathWildcard_with_prefix_constants_matchesDirectFileInNestedDirectory() throws { - try XCTAssertMatches("Target/Extra/Pivot/AutoMockable.generated.swift", pattern: "Target/**/Pivot/**/*.generated.swift") + #expect(try Pattern("Target/**/Pivot/**/*.generated.swift").match("Target/Extra/Pivot/AutoMockable.generated.swift")) } + @Test func test_pathWildcard_matchesMultipleNestedFolders() throws { - try XCTAssertMatches("Target/Generated/AutoMockable.generated.swift", pattern: "**/*.generated.swift") + #expect(try Pattern("**/*.generated.swift").match("Target/Generated/AutoMockable.generated.swift")) } + @Test func test_componentWildcard_matchesNonNestedFiles() throws { - try XCTAssertMatches("AutoMockable.generated.swift", pattern: "*.generated.swift") + #expect(try Pattern("*.generated.swift").match("AutoMockable.generated.swift")) } + @Test func test_componentWildcard_doesNotMatchNestedPaths() throws { - try XCTAssertDoesNotMatch("Target/AutoMockable.generated.swift", pattern: "*.generated.swift") + #expect(!(try Pattern("*.generated.swift").match("Target/AutoMockable.generated.swift"))) } + @Test func test_multipleWildcards_matchesWithMultipleConstants() throws { // this can be tricky for some implementations because as they are parsing the first wildcard, // it will see a match and move on and the remaining pattern and content will not match - try XCTAssertMatches("Target/AutoMockable/Sources/AutoMockable.generated.swift", pattern: "**/AutoMockable*.swift") + #expect(try Pattern("**/AutoMockable*.swift").match("Target/AutoMockable/Sources/AutoMockable.generated.swift")) } + @Test func test_matchingLongStrings_onSecondaryThread_doesNotCrash() async throws { // In Debug when using async methods, long strings would cause crashes with recursion for strings approaching ~90 // characters. @@ -62,57 +71,64 @@ final class PatternTests: XCTestCase { } func runStressTest() async throws { - try XCTAssertMatches( - "base/Shared/Tests/Objects/Utilities/PathsMoreAbitraryStringLengthSomeVeryLongTypeNameThat+SomeLongExtensionNameTests.swift", - pattern: "base/**/Tests/**/*Tests.swift" - ) + #expect(try Pattern("base/**/Tests/**/*Tests.swift").match( + "base/Shared/Tests/Objects/Utilities/PathsMoreAbitraryStringLengthSomeVeryLongTypeNameThat+SomeLongExtensionNameTests.swift" + )) } + @Test func test_pathWildcard_pathComponentsOnly_doesNotMatchPath() throws { var options = Pattern.Options.default options.supportsPathLevelWildcards = false - try XCTAssertDoesNotMatch("Target/Other/.build", pattern: "**/.build", options: options) + #expect(!(try Pattern("**/.build", options: options).match("Target/Other/.build"))) } + @Test func test_componentWildcard_pathComponentsOnly_doesMatchSingleComponent() throws { var options = Pattern.Options.default options.supportsPathLevelWildcards = false - try XCTAssertMatches("Target/.build", pattern: "*/.build", options: options) + #expect(try Pattern("*/.build", options: options).match("Target/.build")) } + @Test func test_constant() throws { - try XCTAssertMatches("abc", pattern: "abc") + #expect(try Pattern("abc").match("abc")) } + @Test func test_ranges() throws { - try XCTAssertMatches("b", pattern: "[a-c]") - try XCTAssertMatches("B", pattern: "[A-C]") - try XCTAssertDoesNotMatch("n", pattern: "[a-c]") + #expect(try Pattern("[a-c]").match("b")) + #expect(try Pattern("[A-C]").match("B")) + #expect(!(try Pattern("[a-c]").match("n"))) } + @Test func test_multipleRanges() throws { - try XCTAssertMatches("b", pattern: "[a-cA-C]") - try XCTAssertMatches("B", pattern: "[a-cA-C]") - try XCTAssertDoesNotMatch("n", pattern: "[a-cA-C]") - try XCTAssertDoesNotMatch("N", pattern: "[a-cA-C]") - try XCTAssertDoesNotMatch("n", pattern: "[a-cA-Z]") - try XCTAssertMatches("N", pattern: "[a-cA-Z]") + #expect(try Pattern("[a-cA-C]").match("b")) + #expect(try Pattern("[a-cA-C]").match("B")) + #expect(!(try Pattern("[a-cA-C]").match("n"))) + #expect(!(try Pattern("[a-cA-C]").match("N"))) + #expect(!(try Pattern("[a-cA-Z]").match("n"))) + #expect(try Pattern("[a-cA-Z]").match("N")) } + @Test func test_negateRange() throws { - try XCTAssertDoesNotMatch("abc", pattern: "ab[^c]", options: .go) + #expect(!(try Pattern("ab[^c]", options: .go).match("abc"))) } + @Test func test_singleCharacter_doesNotMatchSeparator() throws { - try XCTAssertDoesNotMatch("a/b", pattern: "a?b") + #expect(!(try Pattern("a?b").match("a/b"))) } + @Test func test_namedCharacterClasses_alpha() throws { - try XCTAssertMatches("b", pattern: "[[:alpha:]]") - try XCTAssertMatches("B", pattern: "[[:alpha:]]") - try XCTAssertMatches("Ä“", pattern: "[[:alpha:]]") - try XCTAssertMatches("ž", pattern: "[[:alpha:]]") - try XCTAssertDoesNotMatch("9", pattern: "[[:alpha:]]") - try XCTAssertDoesNotMatch("&", pattern: "[[:alpha:]]") + #expect(try Pattern("[[:alpha:]]").match("b")) + #expect(try Pattern("[[:alpha:]]").match("B")) + #expect(try Pattern("[[:alpha:]]").match("Ä“")) + #expect(try Pattern("[[:alpha:]]").match("ž")) + #expect(!(try Pattern("[[:alpha:]]").match("9"))) + #expect(!(try Pattern("[[:alpha:]]").match("&"))) } } diff --git a/Tests/GlobTests/TestHelpers/XCTAssertMatches.swift b/Tests/GlobTests/TestHelpers/XCTAssertMatches.swift deleted file mode 100644 index 5a58cb2..0000000 --- a/Tests/GlobTests/TestHelpers/XCTAssertMatches.swift +++ /dev/null @@ -1,33 +0,0 @@ -import XCTest - -@testable import Glob - -func XCTAssertMatches( - _ value: String, - pattern: String, - options: Glob.Pattern.Options = .default, - file: StaticString = #filePath, - line: UInt = #line -) throws { - try XCTAssertTrue( - Pattern(pattern, options: options).match(value), - "\(value) did not match pattern \(pattern) with options \(options)", - file: file, - line: line - ) -} - -func XCTAssertDoesNotMatch( - _ value: String, - pattern: String, - options: Glob.Pattern.Options = .default, - file: StaticString = #filePath, - line: UInt = #line -) throws { - try XCTAssertFalse( - Pattern(pattern, options: options).match(value), - "'\(value)' matched pattern '\(pattern)' with options \(options)", - file: file, - line: line - ) -} diff --git a/Tests/GlobTests/TestHelpers/XCTExpectFailure.swift b/Tests/GlobTests/TestHelpers/XCTExpectFailure.swift deleted file mode 100644 index dbf71d7..0000000 --- a/Tests/GlobTests/TestHelpers/XCTExpectFailure.swift +++ /dev/null @@ -1,5 +0,0 @@ -// XCTExpectFailure is only available on Apple platforms -// https://github.com/apple/swift-corelibs-xctest/issues/438 -#if !os(macOS) && !os(iOS) && !os(watchOS) && !os(tvOS) - func XCTExpectFailure(_: () throws -> Void) rethrows {} -#endif From 81cc41612339520fdd1e061935c412b8c82e22ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Pi=C3=B1era?= Date: Tue, 17 Mar 2026 16:15:12 +0100 Subject: [PATCH 10/19] fix: use file URLs for glob roots --- Sources/FileSystem/FileSystem.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Sources/FileSystem/FileSystem.swift b/Sources/FileSystem/FileSystem.swift index ee58d9d..3d26405 100644 --- a/Sources/FileSystem/FileSystem.swift +++ b/Sources/FileSystem/FileSystem.swift @@ -913,10 +913,8 @@ public struct FileSystem: FileSysteming, Sendable { let logMessage = "Looking up files and directories from \(directory.pathString) that match the glob patterns \(include.joined(separator: ", "))." logger?.debug("\(logMessage)") - let encodedPath = directory.pathString.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? directory - .pathString return Glob.search( - directory: URL(string: encodedPath)!, + directory: URL.with(filePath: directory.pathString), include: try include .flatMap { try expandBraces(in: $0) } .map { try Pattern($0) }, @@ -927,7 +925,13 @@ public struct FileSystem: FileSysteming, Sendable { skipHiddenFiles: false ) .map { - let path = $0.absoluteString.removingPercentEncoding ?? $0.absoluteString + let path: String + if $0.isFileURL { + let filePath = $0.path() + path = filePath.removingPercentEncoding ?? filePath + } else { + path = $0.absoluteString.removingPercentEncoding ?? $0.absoluteString + } return try Path.AbsolutePath(validating: path) } .eraseToAnyThrowingAsyncSequenceable() From 59e0247807b541d68cefc6a1ebd392431284a2e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Pi=C3=B1era?= Date: Tue, 17 Mar 2026 17:01:14 +0100 Subject: [PATCH 11/19] refactor: restore swift-file-system as backend for FD-throttled async I/O Replace raw POSIX/WinSDK system calls with coenttb/swift-file-system, which provides concurrency-pooled async I/O to prevent file descriptor exhaustion under heavy load. This addresses ulimit issues hit in tuist/tuist. Platform minimum bumped to macOS 26 / iOS 26. --- Package.resolved | 99 +++ Package.swift | 6 +- Sources/FileSystem/FileSystem.swift | 997 ++++++++-------------------- 3 files changed, 369 insertions(+), 733 deletions(-) diff --git a/Package.resolved b/Package.resolved index 2456403..1b03059 100644 --- a/Package.resolved +++ b/Package.resolved @@ -9,6 +9,42 @@ "version" : "0.3.8" } }, + { + "identity" : "swift-async-algorithms-fork", + "kind" : "remoteSourceControl", + "location" : "https://github.com/coenttb/swift-async-algorithms-fork.git", + "state" : { + "revision" : "9352a14c5693451c0f76a433d22dbe86a92f61ae", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "8d9834a6189db730f6264db7556a7ffb751e99ee", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-file-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/coenttb/swift-file-system", + "state" : { + "revision" : "4e65b651641a816e64f61664dbb357ad519fe05a", + "version" : "0.6.0" + } + }, + { + "identity" : "swift-incits-4-1986", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-standards/swift-incits-4-1986", + "state" : { + "revision" : "5e0ac8ce2e69663d690bc12993c9cebefffae613", + "version" : "0.7.1" + } + }, { "identity" : "swift-log", "kind" : "remoteSourceControl", @@ -18,6 +54,69 @@ "version" : "1.10.1" } }, + { + "identity" : "swift-memory-allocation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/coenttb/swift-memory-allocation", + "state" : { + "revision" : "afe7e86f16981007b841470d5ea79f0026868bc3", + "version" : "0.2.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-rfc-4648", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-standards/swift-rfc-4648", + "state" : { + "revision" : "029f384ec63890d98da9a09844bb2ef91a176872", + "version" : "0.6.0" + } + }, + { + "identity" : "swift-standards", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-standards/swift-standards", + "state" : { + "revision" : "948b7642f57f9aac26f4b6d1e3a86b5c1861ecbb", + "version" : "0.30.1" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } + }, + { + "identity" : "swift-testing-performance", + "kind" : "remoteSourceControl", + "location" : "https://github.com/coenttb/swift-testing-performance", + "state" : { + "revision" : "1a1967f7acfcb081e57122db463817c142cc7186", + "version" : "0.3.1" + } + }, { "identity" : "zipfoundation", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index ea95544..3d9dd1b 100644 --- a/Package.swift +++ b/Package.swift @@ -18,8 +18,8 @@ let package = Package( name: "FileSystem", platforms: [ - .macOS("13.0"), - .iOS("16.0"), + .macOS("26.0"), + .iOS("26.0"), ], products: [ .library( @@ -39,6 +39,7 @@ let package = Package( ), ], dependencies: [ + .package(url: "https://github.com/coenttb/swift-file-system", .upToNextMajor(from: "0.6.0")), .package(url: "https://github.com/tuist/Path", .upToNextMajor(from: "0.3.8")), .package(url: "https://github.com/apple/swift-log", .upToNextMajor(from: "1.10.1")), ] + zipFoundationDependency, @@ -47,6 +48,7 @@ let package = Package( name: "FileSystem", dependencies: [ "Glob", + .product(name: "File System", package: "swift-file-system"), .product(name: "Path", package: "Path"), .product(name: "Logging", package: "swift-log"), ] + zipFoundationTarget, diff --git a/Sources/FileSystem/FileSystem.swift b/Sources/FileSystem/FileSystem.swift index 3d26405..01c670b 100644 --- a/Sources/FileSystem/FileSystem.swift +++ b/Sources/FileSystem/FileSystem.swift @@ -1,16 +1,18 @@ +import File_System +import File_System_Primitives import Foundation import Glob import Logging import Path -#if os(Windows) - import WinSDK -#elseif canImport(Darwin) +#if canImport(Darwin) import Darwin #elseif canImport(Glibc) import Glibc #elseif canImport(Musl) import Musl +#elseif os(Windows) + import WinSDK #endif #if !os(Windows) @@ -74,19 +76,19 @@ public enum MakeDirectoryOptions: String { /// Options to configure the writing of text files. public enum WriteTextOptions { - /// When passed, it ovewrites any existing files. + /// When passed, it overwrites any existing files. case overwrite } /// Options to configure the writing of Plist files. public enum WritePlistOptions { - /// When passed, it ovewrites any existing files. + /// When passed, it overwrites any existing files. case overwrite } /// Options to configure the writing of JSON files. public enum WriteJSONOptions { - /// When passed, it ovewrites any existing files. + /// When passed, it overwrites any existing files. case overwrite } @@ -352,15 +354,10 @@ public protocol FileSysteming: Sendable { /// - Returns: An array of `AbsolutePath` objects representing all items in the directory. /// - Throws: An error if the directory cannot be read or accessed. func contentsOfDirectory(_ path: AbsolutePath) async throws -> [AbsolutePath] - - // TODO: - // func urlSafeBase64MD5(path: AbsolutePath) throws -> String - // func fileAttributes(at path: AbsolutePath) throws -> [FileAttributeKey: Any] - // func files(in path: AbsolutePath, nameFilter: Set?, extensionFilter: Set?) -> Set - // func filesAndDirectoriesContained(in path: AbsolutePath) throws -> [AbsolutePath]? } -// swiftlint:disable:next type_body_length +// MARK: - FileSystem + public struct FileSystem: FileSysteming, Sendable { fileprivate let logger: Logger? fileprivate let environmentVariables: [String: String] @@ -371,36 +368,20 @@ public struct FileSystem: FileSysteming, Sendable { } public func currentWorkingDirectory() async throws -> AbsolutePath { - #if os(Windows) - var buffer = [WCHAR](repeating: 0, count: Int(MAX_PATH) + 1) - var length = buffer.withUnsafeMutableBufferPointer { - GetCurrentDirectoryW(DWORD($0.count), $0.baseAddress) - } - guard length > 0 else { throw windowsError() } - if Int(length) >= buffer.count { - buffer = [WCHAR](repeating: 0, count: Int(length) + 1) - length = buffer.withUnsafeMutableBufferPointer { - GetCurrentDirectoryW(DWORD($0.count), $0.baseAddress) - } - guard length > 0, Int(length) < buffer.count else { throw windowsError() } - } - return try AbsolutePath(validating: buffer.withUnsafeBufferPointer { - String(decodingCString: $0.baseAddress!, as: UTF16.self) - }) - #else - var buffer = [CChar](repeating: 0, count: Int(PATH_MAX)) - guard getcwd(&buffer, buffer.count) != nil else { throw posixError() } - return try AbsolutePath(validating: String(cString: buffer)) - #endif + try AbsolutePath(validating: FileManager.default.currentDirectoryPath) } public func contentsOfDirectory(_ path: AbsolutePath) async throws -> [AbsolutePath] { - try contentsOfDirectory(at: path) + let directory = File.Directory(try filePath(path)) + return try await File.Directory.Contents.list(at: directory).compactMap { entry in + guard let entryPath = entry.pathIfValid else { return nil } + return try absolutePath(entryPath) + } } public func exists(_ path: AbsolutePath) async throws -> Bool { logger?.debug("Checking if a file or directory exists at path \(path.pathString).") - return try exists(path, followSymlinks: true) + return await File.System.Stat.exists(at: try filePath(path)) } public func exists(_ path: AbsolutePath, isDirectory: Bool) async throws -> Bool { @@ -409,95 +390,52 @@ public struct FileSystem: FileSysteming, Sendable { } else { logger?.debug("Checking if a file exists at path \(path.pathString).") } - return try exists(path, as: isDirectory ? .directory : .file) + let fp = try filePath(path) + if isDirectory { + return await File.System.Stat.isDirectory(at: fp) + } else { + return await File.System.Stat.isFile(at: fp) + } } public func touch(_ path: Path.AbsolutePath) async throws { logger?.debug("Touching a file at path \(path.pathString).") - - if try exists(path, followSymlinks: true) { + if try await exists(path) { let now = Date() try await setFileTimes(of: path, lastAccessDate: now, lastModificationDate: now) return } - guard try exists(path.parentDirectory, as: .directory) else { + guard try await exists(path.parentDirectory, isDirectory: true) else { throw CocoaError(.fileNoSuchFile) } - #if os(Windows) - let handle = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { wpath in - CreateFileW( - wpath, - DWORD(GENERIC_WRITE), - DWORD(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE), - nil, - DWORD(CREATE_NEW), - DWORD(FILE_ATTRIBUTE_NORMAL), - nil - ) - } - guard let handle, handle != INVALID_HANDLE_VALUE else { throw windowsError() } - CloseHandle(handle) - #else - let descriptor = path.pathString.withCString { pathPointer in - open(pathPointer, O_WRONLY | O_CREAT | O_EXCL, mode_t(0o666)) - } - guard descriptor >= 0 else { throw posixError() } - _ = close(descriptor) - #endif + try await writeFileBytes(Data(), to: path) } public func remove(_ path: AbsolutePath) async throws { logger?.debug("Removing the file or directory at path: \(path.pathString).") - guard try await exists(path) else { return } - try remove(at: path) + let fp = try filePath(path) + guard await File.System.Stat.exists(at: fp) else { return } + try await File.System.Delete.delete(at: fp, options: .init(recursive: true)) } public func makeTemporaryDirectory(prefix: String) async throws -> AbsolutePath { - let temporaryDirectory: AbsolutePath - #if os(Windows) - var buffer = [WCHAR](repeating: 0, count: Int(MAX_PATH) + 1) - var length = buffer.withUnsafeMutableBufferPointer { - GetTempPathW(DWORD($0.count), $0.baseAddress) - } - guard length > 0 else { throw windowsError() } - if Int(length) >= buffer.count { - buffer = [WCHAR](repeating: 0, count: Int(length) + 1) - length = buffer.withUnsafeMutableBufferPointer { - GetTempPathW(DWORD($0.count), $0.baseAddress) - } - guard length > 0, Int(length) < buffer.count else { throw windowsError() } - } - temporaryDirectory = try AbsolutePath( - validating: buffer.withUnsafeBufferPointer { - String(decodingCString: $0.baseAddress!, as: UTF16.self) - } - ) - let path = temporaryDirectory.appending(component: "\(prefix)-\(UUID().uuidString)") - try makeDirectory(at: path, createIntermediates: true) - logger?.debug("Creating a temporary directory at path \(path.pathString).") - return path - #else - var systemTemporaryDirectory = NSTemporaryDirectory() + var systemTemporaryDirectory = NSTemporaryDirectory() - #if os(macOS) - if systemTemporaryDirectory.starts(with: "/var/") { - systemTemporaryDirectory = "/private\(systemTemporaryDirectory)" - } - #endif - - let basePath = try AbsolutePath(validating: systemTemporaryDirectory) - var template = basePath.appending(component: "\(prefix)-XXXXXX").pathString.utf8CString - let createdPath = template.withUnsafeMutableBufferPointer { pointer -> String? in - guard let baseAddress = pointer.baseAddress else { return nil } - guard let pathPointer = mkdtemp(baseAddress) else { return nil } - return String(cString: pathPointer) + #if os(macOS) + if systemTemporaryDirectory.starts(with: "/var/") { + systemTemporaryDirectory = "/private\(systemTemporaryDirectory)" } - guard let createdPath else { throw posixError() } - temporaryDirectory = try AbsolutePath(validating: createdPath) #endif + + let temporaryDirectory = try AbsolutePath(validating: systemTemporaryDirectory) + .appending(component: "\(prefix)-\(UUID().uuidString)") logger?.debug("Creating a temporary directory at path \(temporaryDirectory.pathString).") + try await File.System.Create.Directory.create( + at: try filePath(temporaryDirectory), + options: .init(createIntermediates: true) + ) return temporaryDirectory } @@ -519,10 +457,11 @@ public struct FileSystem: FileSysteming, Sendable { try? await makeDirectory(at: to.parentDirectory, options: [.createTargetParentDirectories]) } } - guard try await exists(from) else { + let sourcePath = try filePath(from) + guard await File.System.Stat.exists(at: sourcePath) else { throw FileSystemError.moveNotFound(from: from, to: to) } - try move(from: from, to: to, ensureDestinationIsAbsent: true) + try await File.System.Move.move(from: sourcePath, to: try filePath(to)) } public func makeDirectory(at: Path.AbsolutePath) async throws { @@ -531,29 +470,26 @@ public struct FileSystem: FileSysteming, Sendable { public func makeDirectory(at: Path.AbsolutePath, options: [MakeDirectoryOptions]) async throws { if options.isEmpty { + logger?.debug("Creating directory at path \(at.pathString).") + } else { logger? .debug( "Creating directory at path \(at.pathString) with options: \(options.map(\.rawValue).joined(separator: ", "))." ) - } else { - logger?.debug("Creating directory at path \(at.pathString).") } let createIntermediates = options.contains(.createTargetParentDirectories) if !createIntermediates, !(try await exists(at.parentDirectory, isDirectory: true)) { throw FileSystemError.makeDirectoryAbsentParent(at) } - try makeDirectory(at: at, createIntermediates: createIntermediates) + try await File.System.Create.Directory.create( + at: try filePath(at), + options: .init(createIntermediates: createIntermediates) + ) } public func readFile(at path: Path.AbsolutePath) async throws -> Data { - try await readFile(at: path, log: true) - } - - private func readFile(at path: Path.AbsolutePath, log: Bool = false) async throws -> Data { - if log { - logger?.debug("Reading file at path \(path.pathString).") - } - return try Data(contentsOf: URL(fileURLWithPath: path.pathString)) + logger?.debug("Reading file at path \(path.pathString).") + return Data(try await File.System.Read.Full.read(from: try filePath(path))) } public func readTextFile(at: Path.AbsolutePath) async throws -> String { @@ -591,7 +527,7 @@ public struct FileSystem: FileSysteming, Sendable { if options.contains(.overwrite), try await exists(path) { try await remove(path) } - try writeFile(data, to: path) + try await writeFileBytes(data, to: path) } public func readPlistFile(at path: Path.AbsolutePath) async throws -> T where T: Decodable { @@ -625,7 +561,7 @@ public struct FileSystem: FileSysteming, Sendable { } let plistData = try encoder.encode(item) - try writeFile(plistData, to: path) + try await writeFileBytes(plistData, to: path) } public func readJSONFile(at path: Path.AbsolutePath) async throws -> T where T: Decodable { @@ -658,32 +594,35 @@ public struct FileSystem: FileSysteming, Sendable { if options.contains(.overwrite), try await exists(path) { try await remove(path) } - try writeFile(json, to: path) + try await writeFileBytes(json, to: path) } public func replace(_ to: AbsolutePath, with path: AbsolutePath) async throws { - logger?.debug("Replacing file or directory at path \(path.pathString) with item at path \(to.pathString).") - if !(try await exists(path)) { + logger?.debug("Replacing file or directory at path \(to.pathString) with item at path \(path.pathString).") + let sourcePath = try filePath(path) + let destinationPath = try filePath(to) + guard await File.System.Stat.exists(at: sourcePath) else { throw FileSystemError.replacingItemAbsent(replacingPath: path, replacedPath: to) } if !(try await exists(to.parentDirectory)) { try await makeDirectory(at: to.parentDirectory) } - if try exists(to, followSymlinks: false) { - try remove(at: to) + if await File.System.Stat.exists(at: destinationPath) { + try await File.System.Delete.delete(at: destinationPath, options: .init(recursive: true)) } - try copy(from: path, to: to, ensureDestinationIsAbsent: true) + try await copyItem(from: sourcePath, to: destinationPath) } public func copy(_ from: AbsolutePath, to: AbsolutePath) async throws { logger?.debug("Copying file or directory at path \(from.pathString) to \(to.pathString).") - if !(try await exists(from)) { + let sourcePath = try filePath(from) + guard await File.System.Stat.exists(at: sourcePath) else { throw FileSystemError.copiedItemAbsent(copiedPath: from, intoPath: to) } if !(try await exists(to.parentDirectory)) { try await makeDirectory(at: to.parentDirectory) } - try copy(from: from, to: to, ensureDestinationIsAbsent: true) + try await copyItem(from: sourcePath, to: try filePath(to)) } public func runInTemporaryDirectory( @@ -720,7 +659,14 @@ public struct FileSystem: FileSysteming, Sendable { public func fileMetadata(at path: AbsolutePath) async throws -> FileMetadata? { logger?.debug("Getting the metadata of file at path \(path.pathString).") - return try metadata(at: path) + let fp = try filePath(path) + guard await File.System.Stat.exists(at: fp) else { return nil } + let info = try await File.System.Stat.info(at: fp) + let modificationTime = info.timestamps.modificationTime + let seconds = TimeInterval(modificationTime.secondsSinceEpoch) + let nanoseconds = TimeInterval(modificationTime.totalNanoseconds) / 1_000_000_000 + let modificationDate = Date(timeIntervalSince1970: seconds + nanoseconds) + return FileMetadata(size: info.size, lastModificationDate: modificationDate) } public func setFileTimes( @@ -730,37 +676,37 @@ public struct FileSystem: FileSysteming, Sendable { ) async throws { logger?.debug("Setting file times at path \(path.pathString).") #if os(Windows) - let handle = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { wpath in - CreateFileW( - wpath, - DWORD(FILE_WRITE_ATTRIBUTES), - DWORD(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE), - nil, - DWORD(OPEN_EXISTING), - DWORD(FILE_ATTRIBUTE_NORMAL), - nil - ) + let handle = path.pathString + .replacingOccurrences(of: "/", with: "\\") + .withCString(encodedAs: UTF16.self) { wpath in + CreateFileW( + wpath, + DWORD(FILE_WRITE_ATTRIBUTES), + DWORD(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE), + nil, + DWORD(OPEN_EXISTING), + DWORD(FILE_ATTRIBUTE_NORMAL), + nil + ) + } + guard let handle, handle != INVALID_HANDLE_VALUE else { + throw NSError(domain: "WinSDK", code: Int(GetLastError())) } - guard let handle, handle != INVALID_HANDLE_VALUE else { throw windowsError() } defer { CloseHandle(handle) } - let accessTime = lastAccessDate.map(windowsFileTime(from:)) - let modificationTime = lastModificationDate.map(windowsFileTime(from:)) - let success: Bool - if let accessTime, let modificationTime { - var accessTime = accessTime - var modificationTime = modificationTime - success = windowsSucceeded(SetFileTime(handle, nil, &accessTime, &modificationTime)) - } else if let accessTime { - var accessTime = accessTime - success = windowsSucceeded(SetFileTime(handle, nil, &accessTime, nil)) - } else if let modificationTime { - var modificationTime = modificationTime - success = windowsSucceeded(SetFileTime(handle, nil, nil, &modificationTime)) + let accessTime = lastAccessDate.map(Self.windowsFileTime(from:)) + let modificationTime = lastModificationDate.map(Self.windowsFileTime(from:)) + var success = true + if var accessTime, var modificationTime { + success = SetFileTime(handle, nil, &accessTime, &modificationTime) != 0 + } else if var accessTime { + success = SetFileTime(handle, nil, &accessTime, nil) != 0 + } else if var modificationTime { + success = SetFileTime(handle, nil, nil, &modificationTime) != 0 } else { return } - guard windowsSucceeded(success) else { throw windowsError() } + guard success else { throw NSError(domain: "WinSDK", code: Int(GetLastError())) } #else try Self.updateFileTimes( path: path.pathString, @@ -770,61 +716,6 @@ public struct FileSystem: FileSysteming, Sendable { #endif } - #if !os(Windows) - private static func updateFileTimes( - path: String, - lastAccessDate: Date?, - lastModificationDate: Date? - ) throws { - var info = stat() - let statResult = path.withCString { stat($0, &info) } - guard statResult == 0 else { - throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) - } - - var times = [ - dateToTimespec(lastAccessDate ?? date(from: accessTimespec(from: info))), - dateToTimespec(lastModificationDate ?? date(from: modificationTimespec(from: info))), - ] - - let result = path.withCString { pathPointer in - utimensat(AT_FDCWD, pathPointer, ×, 0) - } - - guard result == 0 else { - throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) - } - } - - private static func dateToTimespec(_ date: Date) -> timespec { - let seconds = Int(date.timeIntervalSince1970) - let nanoseconds = Int((date.timeIntervalSince1970 - Double(seconds)) * 1_000_000_000) - return timespec(tv_sec: seconds, tv_nsec: nanoseconds) - } - - private static func date(from timespec: timespec) -> Date { - let seconds = TimeInterval(timespec.tv_sec) - let nanoseconds = TimeInterval(timespec.tv_nsec) / 1_000_000_000 - return Date(timeIntervalSince1970: seconds + nanoseconds) - } - - private static func accessTimespec(from info: stat) -> timespec { - #if canImport(Darwin) - info.st_atimespec - #else - info.st_atim - #endif - } - - private static func modificationTimespec(from info: stat) -> timespec { - #if canImport(Darwin) - info.st_mtimespec - #else - info.st_mtim - #endif - } - #endif - public func locateTraversingUp(from: AbsolutePath, relativePath: RelativePath) async throws -> AbsolutePath? { logger?.debug("Locating the relative path \(relativePath.pathString) by traversing up from \(from.pathString).") let path = from.appending(relativePath) @@ -845,38 +736,37 @@ public struct FileSystem: FileSysteming, Sendable { private func createSymbolicLink(fromPathString: String, toPathString: String) async throws { logger?.debug("Creating symbolic link from \(fromPathString) to \(toPathString).") - try makeSymbolicLink(fromPathString: fromPathString, toPathString: toPathString) + try await File.System.Link.Symbolic.create( + at: try File.Path(fromPathString), + pointingTo: try File.Path(toPathString) + ) } public func resolveSymbolicLink(_ symlinkPath: AbsolutePath) async throws -> AbsolutePath { logger?.debug("Resolving symbolink link at path \(symlinkPath.pathString).") - if !(try await exists(symlinkPath)) { + let fp = try filePath(symlinkPath) + guard await File.System.Stat.exists(at: fp) else { throw FileSystemError.absentSymbolicLink(symlinkPath) } - let destination: String do { - destination = try readSymbolicLinkDestination(at: symlinkPath) + let targetPath = try await File.System.Link.Read.Target.target(of: fp) + if targetPath.isAbsolute { + return try absolutePath(targetPath) + } else { + return AbsolutePath( + symlinkPath.parentDirectory, + try RelativePath(validating: String(describing: targetPath)) + ) + } } catch { return symlinkPath } - - #if os(Windows) - if destination.hasPrefix("/") || destination.contains(":") { - return try AbsolutePath(validating: destination) - } - #else - if destination.hasPrefix("/") { - return try AbsolutePath(validating: destination) - } - #endif - - return AbsolutePath(symlinkPath.parentDirectory, try RelativePath(validating: destination)) } #if !os(Windows) public func zipFileOrDirectoryContent(at path: Path.AbsolutePath, to: Path.AbsolutePath) async throws { logger?.debug("Zipping the file or contents of directory at path \(path.pathString) into \(to.pathString)") - try createArchive( + try await createArchive( at: URL(fileURLWithPath: path.pathString), to: URL(fileURLWithPath: to.pathString), shouldKeepParent: false @@ -938,20 +828,17 @@ public struct FileSystem: FileSysteming, Sendable { } } +// MARK: - Collect helper + extension AnyThrowingAsyncSequenceable where Element == Path.AbsolutePath { public func collect() async throws -> [Path.AbsolutePath] { try await reduce(into: [Path.AbsolutePath]()) { $0.append($1) } } } -extension FileSystem { - private func writeFile(_ data: Data, to path: AbsolutePath) throws { - try data.write(to: URL(fileURLWithPath: path.pathString)) - } +// MARK: - Convenience overloads - /// Creates and passes a temporary directory to the given action, coupling its lifecycle to the action's. - /// - Parameter action: The action to run with the temporary directory. - /// - Returns: Any value returned by the action. +extension FileSystem { public func runInTemporaryDirectory( _ action: @Sendable (_ temporaryDirectory: AbsolutePath) async throws -> T ) async throws -> T { @@ -969,535 +856,119 @@ extension FileSystem { public func writeAsJSON(_ item: some Encodable, at path: Path.AbsolutePath, options: Set) async throws { try await writeAsJSON(item, at: path, encoder: JSONEncoder(), options: options) } - - #if !os(Windows) - private func createArchive(at sourceURL: URL, to destinationURL: URL, shouldKeepParent: Bool) throws { - let sourcePath = try AbsolutePath(validating: sourceURL.path) - let destinationPath = try AbsolutePath(validating: destinationURL.path) - guard try exists(sourcePath, followSymlinks: true) else { - throw CocoaError(.fileReadNoSuchFile, userInfo: [NSFilePathErrorKey: sourceURL.path]) - } - guard try !exists(destinationPath, followSymlinks: false) else { - throw CocoaError(.fileWriteFileExists, userInfo: [NSFilePathErrorKey: destinationURL.path]) - } - - let archive = try Archive(url: destinationURL, accessMode: .create) - if try exists(sourcePath, as: .directory) { - let baseURL = shouldKeepParent - ? URL(fileURLWithPath: sourcePath.parentDirectory.pathString) - : sourceURL - let prefix = shouldKeepParent ? "\(sourcePath.basename)/" : "" - for entryPath in try descendantRelativePaths(of: sourcePath) { - try archive.addEntry( - with: "\(prefix)\(entryPath)", - relativeTo: baseURL, - compressionMethod: .none - ) - } - } else { - try archive.addEntry( - with: sourceURL.lastPathComponent, - relativeTo: sourceURL.deletingLastPathComponent(), - compressionMethod: .none - ) - } - } - - private func extractArchive(at sourceURL: URL, to destinationURL: URL) throws { - let sourcePath = try AbsolutePath(validating: sourceURL.path) - guard try exists(sourcePath, followSymlinks: true) else { - throw CocoaError(.fileReadNoSuchFile, userInfo: [NSFilePathErrorKey: sourceURL.path]) - } - - let archive = try Archive(url: sourceURL, accessMode: .read) - for entry in archive { - let entryURL = destinationURL.appendingPathComponent(entry.path) - let checksum = try archive.extract(entry, to: entryURL) - if checksum != entry.checksum { - throw Archive.ArchiveError.invalidCRC32 - } - } - } - #endif } -private enum FileKind { - case directory - case file - case symbolicLink - case other -} - -private struct FileInfo { - let kind: FileKind - let size: Int64 - let modificationDate: Date -} +// MARK: - Path conversion helpers extension FileSystem { - private func contentsOfDirectory(at path: AbsolutePath) throws -> [AbsolutePath] { - #if os(Windows) - guard try exists(path, as: .directory) else { - throw windowsError(DWORD(ERROR_PATH_NOT_FOUND)) - } - - var entries: [AbsolutePath] = [] - var findData = WIN32_FIND_DATAW() - let searchPath = "\(windowsPathString(path.pathString))\\*" - let handle = searchPath.withCString(encodedAs: UTF16.self) { wpath in - FindFirstFileW(wpath, &findData) - } - guard handle != INVALID_HANDLE_VALUE else { throw windowsError() } - defer { FindClose(handle) } - - repeat { - let name = windowsDirectoryEntryName(from: findData) - guard name != ".", name != ".." else { continue } - entries.append(path.appending(component: name)) - } while windowsSucceeded(FindNextFileW(handle, &findData)) - - let lastError = GetLastError() - if lastError != DWORD(ERROR_NO_MORE_FILES) { - throw windowsError(lastError) - } - - return entries - #else - guard let directory = opendir(path.pathString) else { throw posixError() } - defer { closedir(directory) } - - var entries: [AbsolutePath] = [] - errno = 0 - while let entryPointer = readdir(directory) { - let entry = entryPointer.pointee - var entryName = entry.d_name - let capacity = MemoryLayout.size(ofValue: entryName) / MemoryLayout.size - let name = withUnsafePointer(to: &entryName) { pointer in - pointer.withMemoryRebound( - to: CChar.self, - capacity: capacity - ) { - String(cString: $0) - } - } - guard name != ".", name != ".." else { continue } - entries.append(path.appending(component: name)) - } - - if errno != 0 { - throw posixError() - } - - return entries - #endif + fileprivate func filePath(_ path: AbsolutePath) throws -> File.Path { + try File.Path(path.pathString) } - private func exists(_ path: AbsolutePath, as kind: FileKind) throws -> Bool { - guard let info = try fileInfo(at: path, followSymlinks: true) else { return false } - return info.kind == kind + fileprivate func absolutePath(_ path: File.Path) throws -> AbsolutePath { + try AbsolutePath(validating: String(describing: path)) } +} - private func exists(_ path: AbsolutePath, followSymlinks: Bool) throws -> Bool { - try fileInfo(at: path, followSymlinks: followSymlinks) != nil - } - - private func remove(at path: AbsolutePath) throws { - #if os(Windows) - let attributes = windowsAttributes(atPath: path.pathString) - guard attributes != INVALID_FILE_ATTRIBUTES else { return } - - let isDirectory = (attributes & DWORD(FILE_ATTRIBUTE_DIRECTORY)) != 0 - let isReparsePoint = (attributes & DWORD(FILE_ATTRIBUTE_REPARSE_POINT)) != 0 - if isDirectory, !isReparsePoint { - for child in try contentsOfDirectory(at: path) { - try remove(at: child) - } - let success = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { - RemoveDirectoryW($0) - } - guard windowsSucceeded(success) else { throw windowsError() } - } else if isDirectory { - let success = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { - RemoveDirectoryW($0) - } - guard windowsSucceeded(success) else { throw windowsError() } - } else { - let success = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { - DeleteFileW($0) - } - guard windowsSucceeded(success) else { throw windowsError() } - } - #else - guard let info = try fileInfo(at: path, followSymlinks: false) else { return } - switch info.kind { - case .directory: - for child in try contentsOfDirectory(at: path) { - try remove(at: child) - } - let result = path.pathString.withCString { rmdir($0) } - guard result == 0 else { throw posixError() } - case .file, .symbolicLink, .other: - let result = path.pathString.withCString { unlink($0) } - guard result == 0 else { throw posixError() } - } - #endif - } - - private func move(from: AbsolutePath, to: AbsolutePath, ensureDestinationIsAbsent: Bool) throws { - if ensureDestinationIsAbsent, try exists(to, followSymlinks: false) { - throw fileExistsError(at: to) - } - - #if os(Windows) - let success = windowsPathString(from.pathString).withCString(encodedAs: UTF16.self) { wsrc in - windowsPathString(to.pathString).withCString(encodedAs: UTF16.self) { wdst in - MoveFileExW(wsrc, wdst, DWORD(MOVEFILE_COPY_ALLOWED)) - } - } - guard windowsSucceeded(success) else { throw windowsError() } - #else - let result = from.pathString.withCString { sourcePointer in - to.pathString.withCString { destinationPointer in - rename(sourcePointer, destinationPointer) - } - } - guard result == 0 else { - let error = errno - if error == EXDEV { - try copy(from: from, to: to, ensureDestinationIsAbsent: false) - try remove(at: from) - return - } - throw posixError(error) - } - #endif - } +// MARK: - File I/O helpers - private func makeDirectory(at path: AbsolutePath, createIntermediates: Bool) throws { - #if os(Windows) - if let existing = try fileInfo(at: path, followSymlinks: false) { - guard existing.kind == .directory else { throw windowsError(DWORD(ERROR_ALREADY_EXISTS)) } - return - } - if createIntermediates { - let parent = path.parentDirectory - if parent != path { - try makeDirectory(at: parent, createIntermediates: true) - } - } - let success = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { wpath in - CreateDirectoryW(wpath, nil) - } - guard windowsSucceeded(success) else { - let error = GetLastError() - if error == DWORD(ERROR_ALREADY_EXISTS), try exists(path, as: .directory) { - return - } - throw windowsError(error) - } - #else - if let existing = try fileInfo(at: path, followSymlinks: false) { - guard existing.kind == .directory else { throw posixError(EEXIST) } - return - } - if createIntermediates { - let parent = path.parentDirectory - if parent != path { - try makeDirectory(at: parent, createIntermediates: true) - } - } - let result = path.pathString.withCString { mkdir($0, mode_t(0o755)) } - guard result == 0 else { - let error = errno - if error == EEXIST, try exists(path, as: .directory) { - return - } - throw posixError(error) - } - #endif +extension FileSystem { + fileprivate func writeFileBytes(_ data: Data, to path: AbsolutePath) async throws { + try await File.System.Write.Atomic.write( + [UInt8](data), + to: try filePath(path), + options: .init(strategy: .noClobber, createIntermediates: false) + ) } - private func copy(from: AbsolutePath, to: AbsolutePath, ensureDestinationIsAbsent: Bool) throws { - if ensureDestinationIsAbsent, try exists(to, followSymlinks: false) { - throw fileExistsError(at: to) - } - - guard let info = try fileInfo(at: from, followSymlinks: false) else { - throw CocoaError(.fileReadNoSuchFile, userInfo: [NSFilePathErrorKey: from.pathString]) + fileprivate func copyItem(from source: File.Path, to destination: File.Path) async throws { + guard !(await File.System.Stat.exists(at: destination)) else { + throw CocoaError(.fileWriteFileExists, userInfo: [NSFilePathErrorKey: String(describing: destination)]) } - switch info.kind { + let metadata = try File.System.Stat.lstatInfo(at: source) + switch metadata.type { case .directory: - try makeDirectory(at: to, createIntermediates: false) - for child in try contentsOfDirectory(at: from) { - try copy(from: child, to: to.appending(component: child.basename), ensureDestinationIsAbsent: true) + try await File.System.Create.Directory.create(at: destination, options: .init(createIntermediates: false)) + let entries = try await File.Directory.Contents.list(at: File.Directory(source)) + for entry in entries { + guard let sourceChild = entry.pathIfValid else { continue } + guard let lastComponent = sourceChild.lastComponent else { continue } + try await copyItem(from: sourceChild, to: destination / lastComponent) } case .symbolicLink: - let destination = try readSymbolicLinkDestination(at: from) - try makeSymbolicLink(fromPathString: to.pathString, toPathString: destination) - case .file, .other: - #if os(Windows) - let success = windowsPathString(from.pathString).withCString(encodedAs: UTF16.self) { wsrc in - windowsPathString(to.pathString).withCString(encodedAs: UTF16.self) { wdst in - CopyFileW(wsrc, wdst, true) - } - } - guard windowsSucceeded(success) else { throw windowsError() } - #else - try copyRegularFile(from: from, to: to) - #endif + let target = try await File.System.Link.Read.Target.target(of: source) + try await File.System.Link.Symbolic.create(at: destination, pointingTo: target) + default: + try await File.System.Copy.copy( + from: source, + to: destination, + options: .init(overwrite: false, copyAttributes: true, followSymlinks: false) + ) } } +} - private func metadata(at path: AbsolutePath) throws -> FileMetadata? { - guard let info = try fileInfo(at: path, followSymlinks: true) else { return nil } - return FileMetadata(size: info.size, lastModificationDate: info.modificationDate) - } - - private func makeSymbolicLink(fromPathString: String, toPathString: String) throws { - #if os(Windows) - var flags = DWORD(0x2) - let targetAttributes = windowsAttributes(atPath: toPathString) - if targetAttributes != INVALID_FILE_ATTRIBUTES, - (targetAttributes & DWORD(FILE_ATTRIBUTE_DIRECTORY)) != 0 - { - flags |= DWORD(SYMBOLIC_LINK_FLAG_DIRECTORY) - } - let success = windowsPathString(fromPathString).withCString(encodedAs: UTF16.self) { wlink in - windowsPathString(toPathString).withCString(encodedAs: UTF16.self) { wtarget in - CreateSymbolicLinkW(wlink, wtarget, flags) - } - } - guard windowsSucceeded(success) else { throw windowsError() } - #else - let result = fromPathString.withCString { linkPointer in - toPathString.withCString { targetPointer in - symlink(targetPointer, linkPointer) - } - } - guard result == 0 else { throw posixError() } - #endif - } - - private func readSymbolicLinkDestination(at path: AbsolutePath) throws -> String { - #if os(Windows) - let handle = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { wpath in - CreateFileW( - wpath, - 0, - DWORD(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE), - nil, - DWORD(OPEN_EXISTING), - DWORD(FILE_FLAG_BACKUP_SEMANTICS), - nil - ) - } - guard let handle, handle != INVALID_HANDLE_VALUE else { throw windowsError() } - defer { CloseHandle(handle) } - - var buffer = [WCHAR](repeating: 0, count: Int(MAX_PATH) + 1) - var length = buffer.withUnsafeMutableBufferPointer { - GetFinalPathNameByHandleW(handle, $0.baseAddress, DWORD($0.count), DWORD(FILE_NAME_NORMALIZED)) - } - guard length > 0 else { throw windowsError() } - if Int(length) >= buffer.count { - buffer = [WCHAR](repeating: 0, count: Int(length) + 1) - length = buffer.withUnsafeMutableBufferPointer { - GetFinalPathNameByHandleW(handle, $0.baseAddress, DWORD($0.count), DWORD(FILE_NAME_NORMALIZED)) - } - guard length > 0, Int(length) < buffer.count else { throw windowsError() } - } +// MARK: - File time helpers (raw system calls, since StandardTime.Time is @_spi(Internal)) - var resolvedPath = buffer.withUnsafeBufferPointer { - String(decodingCString: $0.baseAddress!, as: UTF16.self) - } - if resolvedPath.hasPrefix("\\\\?\\UNC\\") { - resolvedPath = "\\\(resolvedPath.dropFirst(7))" - } else if resolvedPath.hasPrefix("\\\\?\\") { - resolvedPath.removeFirst(4) +#if !os(Windows) + extension FileSystem { + fileprivate static func updateFileTimes( + path: String, + lastAccessDate: Date?, + lastModificationDate: Date? + ) throws { + var info = stat() + let statResult = path.withCString { stat($0, &info) } + guard statResult == 0 else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) } - return resolvedPath - #else - var buffer = [CChar](repeating: 0, count: Int(PATH_MAX) + 1) - let length = path.pathString.withCString { readlink($0, &buffer, buffer.count - 1) } - guard length >= 0 else { throw posixError() } - buffer[Int(length)] = 0 - return String(cString: buffer) - #endif - } - private func descendantRelativePaths(of root: AbsolutePath) throws -> [String] { - try descendantRelativePaths(of: root, prefix: "") - } - - private func descendantRelativePaths(of directory: AbsolutePath, prefix: String) throws -> [String] { - var descendants: [String] = [] - for child in try contentsOfDirectory(at: directory) { - let relativePath = prefix.isEmpty ? child.basename : "\(prefix)/\(child.basename)" - descendants.append(relativePath) + var times = [ + dateToTimespec(lastAccessDate ?? date(from: accessTimespec(from: info))), + dateToTimespec(lastModificationDate ?? date(from: modificationTimespec(from: info))), + ] - if try fileInfo(at: child, followSymlinks: false)?.kind == .directory { - descendants.append(contentsOf: try descendantRelativePaths(of: child, prefix: relativePath)) + let result = path.withCString { pathPointer in + utimensat(AT_FDCWD, pathPointer, ×, 0) } - } - return descendants - } - private func fileInfo(at path: AbsolutePath, followSymlinks: Bool) throws -> FileInfo? { - #if os(Windows) - var findData = WIN32_FIND_DATAW() - let handle = windowsPathString(path.pathString).withCString(encodedAs: UTF16.self) { wpath in - FindFirstFileW(wpath, &findData) - } - guard handle != INVALID_HANDLE_VALUE else { - let error = GetLastError() - if error == DWORD(ERROR_FILE_NOT_FOUND) || error == DWORD(ERROR_PATH_NOT_FOUND) { - return nil - } - throw windowsError(error) - } - defer { FindClose(handle) } - - let attributes = findData.dwFileAttributes - let kind: FileKind - if (attributes & DWORD(FILE_ATTRIBUTE_DIRECTORY)) != 0 { - kind = .directory - } else if (attributes & DWORD(FILE_ATTRIBUTE_REPARSE_POINT)) != 0 { - kind = .symbolicLink - } else { - kind = .file - } - let size = (Int64(findData.nFileSizeHigh) << 32) | Int64(findData.nFileSizeLow) - return FileInfo( - kind: kind, - size: size, - modificationDate: windowsDate(from: findData.ftLastWriteTime) - ) - #else - var info = stat() - let result = path.pathString.withCString { pathPointer in - if followSymlinks { - stat(pathPointer, &info) - } else { - lstat(pathPointer, &info) - } - } guard result == 0 else { - let error = errno - if error == ENOENT || error == ENOTDIR { - return nil - } - throw posixError(error) - } - - return FileInfo( - kind: posixFileKind(from: info), - size: Int64(info.st_size), - modificationDate: posixModificationDate(from: info) - ) - #endif - } - - #if !os(Windows) - private func copyRegularFile(from: AbsolutePath, to: AbsolutePath) throws { - let sourceDescriptor = from.pathString.withCString { open($0, O_RDONLY) } - guard sourceDescriptor >= 0 else { throw posixError() } - defer { _ = close(sourceDescriptor) } - - let destinationDescriptor = to.pathString.withCString { - open($0, O_WRONLY | O_CREAT | O_EXCL | O_TRUNC, mode_t(0o666)) + throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) } - guard destinationDescriptor >= 0 else { throw posixError() } - defer { _ = close(destinationDescriptor) } + } - var buffer = [UInt8](repeating: 0, count: 64 * 1024) - while true { - let readCount = buffer.withUnsafeMutableBytes { - read(sourceDescriptor, $0.baseAddress, $0.count) - } - guard readCount >= 0 else { throw posixError() } - guard readCount > 0 else { return } - - var written = 0 - while written < readCount { - let writeCount = buffer.withUnsafeBytes { rawBuffer -> Int in - let baseAddress = rawBuffer.baseAddress!.advanced(by: written) - return write(destinationDescriptor, baseAddress, readCount - written) - } - guard writeCount >= 0 else { throw posixError() } - written += writeCount - } - } + fileprivate static func dateToTimespec(_ date: Date) -> timespec { + let seconds = Int(date.timeIntervalSince1970) + let nanoseconds = Int((date.timeIntervalSince1970 - Double(seconds)) * 1_000_000_000) + return timespec(tv_sec: seconds, tv_nsec: nanoseconds) } - private func posixFileKind(from info: stat) -> FileKind { - switch info.st_mode & S_IFMT { - case S_IFDIR: - return .directory - case S_IFLNK: - return .symbolicLink - case S_IFREG: - return .file - default: - return .other - } + fileprivate static func date(from timespec: timespec) -> Date { + let seconds = TimeInterval(timespec.tv_sec) + let nanoseconds = TimeInterval(timespec.tv_nsec) / 1_000_000_000 + return Date(timeIntervalSince1970: seconds + nanoseconds) } - private func posixModificationDate(from info: stat) -> Date { + fileprivate static func accessTimespec(from info: stat) -> timespec { #if canImport(Darwin) - let seconds = TimeInterval(info.st_mtimespec.tv_sec) - let nanoseconds = TimeInterval(info.st_mtimespec.tv_nsec) / 1_000_000_000 + info.st_atimespec #else - let seconds = TimeInterval(info.st_mtim.tv_sec) - let nanoseconds = TimeInterval(info.st_mtim.tv_nsec) / 1_000_000_000 + info.st_atim #endif - return Date(timeIntervalSince1970: seconds + nanoseconds) } - private func posixError(_ code: Int32 = errno) -> NSError { - NSError(domain: NSPOSIXErrorDomain, code: Int(code)) - } - #else - private func windowsAttributes(atPath path: String) -> DWORD { - windowsPathString(path).withCString(encodedAs: UTF16.self) { - GetFileAttributesW($0) - } - } - - private func windowsSucceeded(_ result: Bool) -> Bool { - result - } - - private func windowsSucceeded(_ result: some BinaryInteger) -> Bool { - result != 0 - } - - private func windowsPathString(_ path: String) -> String { - path.replacingOccurrences(of: "/", with: "\\") - } - - private func windowsDirectoryEntryName(from findData: WIN32_FIND_DATAW) -> String { - var fileName = findData.cFileName - let capacity = MemoryLayout.size(ofValue: fileName) / MemoryLayout.size - return withUnsafePointer(to: &fileName) { pointer in - pointer.withMemoryRebound( - to: WCHAR.self, - capacity: capacity - ) { - String(decodingCString: $0, as: UTF16.self) - } - } - } - - private func windowsDate(from fileTime: FILETIME) -> Date { - let intervals = (Int64(fileTime.dwHighDateTime) << 32) | Int64(fileTime.dwLowDateTime) - let unixIntervals = intervals - 116_444_736_000_000_000 - let seconds = TimeInterval(unixIntervals / 10_000_000) - let nanoseconds = TimeInterval(unixIntervals % 10_000_000) / 10_000_000 - return Date(timeIntervalSince1970: seconds + nanoseconds) + fileprivate static func modificationTimespec(from info: stat) -> timespec { + #if canImport(Darwin) + info.st_mtimespec + #else + info.st_mtim + #endif } - - private func windowsFileTime(from date: Date) -> FILETIME { + } +#else + extension FileSystem { + fileprivate static func windowsFileTime(from date: Date) -> FILETIME { let timeInterval = date.timeIntervalSince1970 let wholeSeconds = Int64(timeInterval) let remainder = timeInterval - TimeInterval(wholeSeconds) @@ -1509,19 +980,83 @@ extension FileSystem { dwHighDateTime: DWORD((intervals >> 32) & 0xFFFF_FFFF) ) } + } +#endif + +// MARK: - Archive helpers + +#if !os(Windows) + extension FileSystem { + fileprivate func createArchive(at sourceURL: URL, to destinationURL: URL, shouldKeepParent: Bool) async throws { + let sourcePath = try AbsolutePath(validating: sourceURL.path) + let destinationPath = try AbsolutePath(validating: destinationURL.path) + let sourceFP = try filePath(sourcePath) + let destinationFP = try filePath(destinationPath) + guard await File.System.Stat.exists(at: sourceFP) else { + throw CocoaError(.fileReadNoSuchFile, userInfo: [NSFilePathErrorKey: sourceURL.path]) + } + guard !(await File.System.Stat.exists(at: destinationFP)) else { + throw CocoaError(.fileWriteFileExists, userInfo: [NSFilePathErrorKey: destinationURL.path]) + } + + let archive = try Archive(url: destinationURL, accessMode: .create) + if await File.System.Stat.isDirectory(at: sourceFP) { + let baseURL = shouldKeepParent + ? URL(fileURLWithPath: sourcePath.parentDirectory.pathString) + : sourceURL + let prefix = shouldKeepParent ? "\(sourcePath.basename)/" : "" + for entryPath in try await descendantRelativePaths(of: sourcePath) { + try archive.addEntry( + with: "\(prefix)\(entryPath)", + relativeTo: baseURL, + compressionMethod: .none + ) + } + } else { + try archive.addEntry( + with: sourceURL.lastPathComponent, + relativeTo: sourceURL.deletingLastPathComponent(), + compressionMethod: .none + ) + } + } + + fileprivate func extractArchive(at sourceURL: URL, to destinationURL: URL) throws { + let sourcePath = try AbsolutePath(validating: sourceURL.path) + guard File.System.Stat.exists(at: try filePath(sourcePath)) else { + throw CocoaError(.fileReadNoSuchFile, userInfo: [NSFilePathErrorKey: sourceURL.path]) + } - private func windowsError(_ code: DWORD = GetLastError()) -> NSError { - NSError(domain: "WinSDK", code: Int(code)) + let archive = try Archive(url: sourceURL, accessMode: .read) + for entry in archive { + let entryURL = destinationURL.appendingPathComponent(entry.path) + let checksum = try archive.extract(entry, to: entryURL) + if checksum != entry.checksum { + throw Archive.ArchiveError.invalidCRC32 + } + } } - private func fileExistsError(at path: AbsolutePath) -> CocoaError { - CocoaError(.fileWriteFileExists, userInfo: [NSFilePathErrorKey: path.pathString]) + fileprivate func descendantRelativePaths(of root: AbsolutePath) async throws -> [String] { + try await descendantRelativePaths(of: root, prefix: "") } - #endif - #if !os(Windows) - private func fileExistsError(at path: AbsolutePath) -> CocoaError { - CocoaError(.fileWriteFileExists, userInfo: [NSFilePathErrorKey: path.pathString]) + fileprivate func descendantRelativePaths(of directory: AbsolutePath, prefix: String) async throws -> [String] { + var descendants: [String] = [] + let dirFP = try filePath(directory) + let entries = try await File.Directory.Contents.list(at: File.Directory(dirFP)) + for entry in entries { + guard let entryPath = entry.pathIfValid else { continue } + let name = String(describing: entryPath.lastComponent ?? File.Path.Component("")) + let relativePath = prefix.isEmpty ? name : "\(prefix)/\(name)" + descendants.append(relativePath) + + if entry.type == .directory { + let childAbsPath = directory.appending(component: name) + descendants.append(contentsOf: try await descendantRelativePaths(of: childAbsPath, prefix: relativePath)) + } + } + return descendants } - #endif -} + } +#endif From 37fadad366392af188991b943d0af1d4e7efb268 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Pi=C3=B1era?= Date: Tue, 17 Mar 2026 17:03:06 +0100 Subject: [PATCH 12/19] fix: handle Windows URL path format in glob directory listing URL.path() on Windows returns /C:/... which is invalid for Win32 APIs. Strip the leading slash when a drive letter follows. Also use URL(fileURLWithPath:) on Windows for consistent file URL construction. --- Sources/Glob/GlobSearch.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/Sources/Glob/GlobSearch.swift b/Sources/Glob/GlobSearch.swift index ef6131a..4b61ce3 100644 --- a/Sources/Glob/GlobSearch.swift +++ b/Sources/Glob/GlobSearch.swift @@ -326,8 +326,15 @@ private func normalizedFileURL(_ url: URL) -> URL { } private func decodedPath(_ url: URL) -> String { - let path = url.path() - return path.removingPercentEncoding ?? path + var path = url.path() + path = path.removingPercentEncoding ?? path + #if os(Windows) + // URL.path() on Windows returns "/C:/..." which is invalid for Win32 APIs + if path.count >= 3, path.hasPrefix("/"), path[path.index(path.startIndex, offsetBy: 2)] == ":" { + path = String(path.dropFirst()) + } + #endif + return path } #if os(Windows) @@ -355,7 +362,7 @@ private func decodedPath(_ url: URL) -> String { extension URL { public static func with(filePath: String) -> URL { - #if os(Linux) + #if os(Linux) || os(Windows) return URL(fileURLWithPath: filePath) #else return URL(filePath: filePath) From 383ea00fb75516893d8595dac87a3ad54f31b998 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Pi=C3=B1era?= Date: Tue, 17 Mar 2026 17:08:48 +0100 Subject: [PATCH 13/19] fix: bump swift-tools-version to 6.2 for swift-file-system compatibility --- Package.swift | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/Package.swift b/Package.swift index 3d9dd1b..3086eec 100644 --- a/Package.swift +++ b/Package.swift @@ -1,7 +1,7 @@ -// swift-tools-version: 5.8.1 +// swift-tools-version: 6.2 // The swift-tools-version declares the minimum version of Swift required to build this package. -@preconcurrency import PackageDescription +import PackageDescription #if os(Windows) let zipFoundationDependency: [Package.Dependency] = [] @@ -76,10 +76,7 @@ let package = Package( ] ), .target( - name: "Glob", - swiftSettings: [ - .enableExperimentalFeature("StrictConcurrency"), - ] + name: "Glob" ), .testTarget( name: "GlobTests", From b0b4812631e02dcbb039a3042f91d42d1cec65a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Pi=C3=B1era?= Date: Tue, 17 Mar 2026 17:18:11 +0100 Subject: [PATCH 14/19] fix: update CI toolchains to Swift 6.2 Bump Docker images to swift:6.2, Windows to swift-6.2-release, and macOS runners to macos-26 for Xcode 26 / Swift 6.2 support. --- .github/workflows/file-system.yml | 17 +++++++---------- .mise/tasks/build-linux | 2 +- .mise/tasks/test-linux | 2 +- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/.github/workflows/file-system.yml b/.github/workflows/file-system.yml index 8aab1ef..59181b3 100644 --- a/.github/workflows/file-system.yml +++ b/.github/workflows/file-system.yml @@ -18,10 +18,9 @@ concurrency: jobs: build: name: "Release build on macOS" - runs-on: macos-15 + runs-on: macos-26 steps: - uses: actions/checkout@v6 - - run: sudo xcode-select -s /Applications/Xcode_16.3.app - uses: jdx/mise-action@v3 - name: Run run: mise run build-spm @@ -37,10 +36,9 @@ jobs: test: name: "Test on macOS" - runs-on: macos-15 + runs-on: macos-26 steps: - uses: actions/checkout@v6 - - run: sudo xcode-select -s /Applications/Xcode_16.3.app - uses: jdx/mise-action@v3 - name: Run run: mise run test-spm @@ -63,8 +61,8 @@ jobs: - uses: compnerd/gha-setup-vsdevenv@main - uses: compnerd/gha-setup-swift@main with: - swift-version: swift-6.0.3-release - swift-build: 6.0.3-RELEASE + swift-version: swift-6.2-release + swift-build: 6.2-RELEASE update-sdk-modules: true - name: Build run: swift build --configuration release @@ -78,18 +76,17 @@ jobs: - uses: compnerd/gha-setup-vsdevenv@main - uses: compnerd/gha-setup-swift@main with: - swift-version: swift-6.0.3-release - swift-build: 6.0.3-RELEASE + swift-version: swift-6.2-release + swift-build: 6.2-RELEASE update-sdk-modules: true - name: Test run: swift test lint: name: Lint - runs-on: macos-15 + runs-on: macos-26 steps: - uses: actions/checkout@v6 - - run: sudo xcode-select -s /Applications/Xcode_16.3.app - uses: jdx/mise-action@v3 - name: Run run: mise run lint diff --git a/.mise/tasks/build-linux b/.mise/tasks/build-linux index 05a90d3..44ec8af 100755 --- a/.mise/tasks/build-linux +++ b/.mise/tasks/build-linux @@ -12,6 +12,6 @@ fi $CONTAINER_RUNTIME run --rm \ --volume "$MISE_PROJECT_ROOT:/package" \ --workdir "/package" \ - swift:6.1.0 \ + swift:6.2 \ /bin/bash -c \ "swift build --configuration release --build-path ./.build/linux" diff --git a/.mise/tasks/test-linux b/.mise/tasks/test-linux index 6d81a8d..204057a 100755 --- a/.mise/tasks/test-linux +++ b/.mise/tasks/test-linux @@ -12,6 +12,6 @@ fi $CONTAINER_RUNTIME run --rm \ --volume "$MISE_PROJECT_ROOT:/package" \ --workdir "/package" \ - swift:6.1.0 \ + swift:6.2 \ /bin/bash -c \ "swift test" From 1284300fab491b9e8b5f794c4b39747ef05e99a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Pi=C3=B1era?= Date: Tue, 17 Mar 2026 17:31:47 +0100 Subject: [PATCH 15/19] fix: implement symlink-safe recursive delete swift-file-system's Delete uses stat() which follows symlinks, causing failures when deleting directories containing symlinks whose targets don't exist. Implement our own lstat-based recursive delete that handles symlinks correctly by unlinking them directly. --- Sources/FileSystem/FileSystem.swift | 56 +++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/Sources/FileSystem/FileSystem.swift b/Sources/FileSystem/FileSystem.swift index 01c670b..e8c75ed 100644 --- a/Sources/FileSystem/FileSystem.swift +++ b/Sources/FileSystem/FileSystem.swift @@ -416,8 +416,7 @@ public struct FileSystem: FileSysteming, Sendable { public func remove(_ path: AbsolutePath) async throws { logger?.debug("Removing the file or directory at path: \(path.pathString).") let fp = try filePath(path) - guard await File.System.Stat.exists(at: fp) else { return } - try await File.System.Delete.delete(at: fp, options: .init(recursive: true)) + try removeItem(at: fp) } public func makeTemporaryDirectory(prefix: String) async throws -> AbsolutePath { @@ -608,7 +607,7 @@ public struct FileSystem: FileSysteming, Sendable { try await makeDirectory(at: to.parentDirectory) } if await File.System.Stat.exists(at: destinationPath) { - try await File.System.Delete.delete(at: destinationPath, options: .init(recursive: true)) + try removeItem(at: destinationPath) } try await copyItem(from: sourcePath, to: destinationPath) } @@ -870,6 +869,57 @@ extension FileSystem { } } +// MARK: - Remove helper (symlink-safe recursive delete) + +extension FileSystem { + /// Removes a file, symlink, or directory (recursively) using lstat to avoid + /// following symlinks. This works around a bug in swift-file-system's Delete + /// which uses stat() and fails on symlinks whose targets don't exist. + fileprivate func removeItem(at path: File.Path) throws { + let info: File.System.Metadata.Info + do { + info = try File.System.Stat.lstatInfo(at: path) + } catch { + return // Path doesn't exist + } + + switch info.type { + case .directory: + let entries = try File.Directory.Contents.list(at: File.Directory(path)) + for entry in entries { + guard let childPath = entry.pathIfValid else { continue } + try removeItem(at: childPath) + } + #if os(Windows) + let success = String(describing: path) + .replacingOccurrences(of: "/", with: "\\") + .withCString(encodedAs: UTF16.self) { RemoveDirectoryW($0) } + guard success != 0 else { + throw NSError(domain: "WinSDK", code: Int(GetLastError())) + } + #else + guard rmdir(String(describing: path)) == 0 else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) + } + #endif + default: + // Files, symlinks, and everything else: unlink directly + #if os(Windows) + let success = String(describing: path) + .replacingOccurrences(of: "/", with: "\\") + .withCString(encodedAs: UTF16.self) { DeleteFileW($0) } + guard success != 0 else { + throw NSError(domain: "WinSDK", code: Int(GetLastError())) + } + #else + guard unlink(String(describing: path)) == 0 else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) + } + #endif + } + } +} + // MARK: - File I/O helpers extension FileSystem { From 519ecf221085b14d699c3369b95842ed1b74a141 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Pi=C3=B1era?= Date: Tue, 17 Mar 2026 17:44:41 +0100 Subject: [PATCH 16/19] fix: correct Windows API return type handling SetFileTime, DeleteFileW, and RemoveDirectoryW return Bool on Windows Swift, not Int. Remove the != 0 comparisons. --- Sources/FileSystem/FileSystem.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Sources/FileSystem/FileSystem.swift b/Sources/FileSystem/FileSystem.swift index e8c75ed..ea87442 100644 --- a/Sources/FileSystem/FileSystem.swift +++ b/Sources/FileSystem/FileSystem.swift @@ -697,11 +697,11 @@ public struct FileSystem: FileSysteming, Sendable { let modificationTime = lastModificationDate.map(Self.windowsFileTime(from:)) var success = true if var accessTime, var modificationTime { - success = SetFileTime(handle, nil, &accessTime, &modificationTime) != 0 + success = SetFileTime(handle, nil, &accessTime, &modificationTime) } else if var accessTime { - success = SetFileTime(handle, nil, &accessTime, nil) != 0 + success = SetFileTime(handle, nil, &accessTime, nil) } else if var modificationTime { - success = SetFileTime(handle, nil, nil, &modificationTime) != 0 + success = SetFileTime(handle, nil, nil, &modificationTime) } else { return } @@ -891,10 +891,10 @@ extension FileSystem { try removeItem(at: childPath) } #if os(Windows) - let success = String(describing: path) + let rmdirSuccess = String(describing: path) .replacingOccurrences(of: "/", with: "\\") .withCString(encodedAs: UTF16.self) { RemoveDirectoryW($0) } - guard success != 0 else { + guard rmdirSuccess else { throw NSError(domain: "WinSDK", code: Int(GetLastError())) } #else @@ -905,10 +905,10 @@ extension FileSystem { default: // Files, symlinks, and everything else: unlink directly #if os(Windows) - let success = String(describing: path) + let deleteSuccess = String(describing: path) .replacingOccurrences(of: "/", with: "\\") .withCString(encodedAs: UTF16.self) { DeleteFileW($0) } - guard success != 0 else { + guard deleteSuccess else { throw NSError(domain: "WinSDK", code: Int(GetLastError())) } #else From 614b213e694c11d86040ff95a35255e89d4d7096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Pi=C3=B1era?= Date: Tue, 17 Mar 2026 17:58:15 +0100 Subject: [PATCH 17/19] fix: use macos-15 with Xcode 26 beta for macOS CI macos-26 runners are not yet available. Use macos-15 with explicit Xcode 26 beta selection instead. --- .github/workflows/file-system.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/file-system.yml b/.github/workflows/file-system.yml index 59181b3..a68a8bf 100644 --- a/.github/workflows/file-system.yml +++ b/.github/workflows/file-system.yml @@ -18,9 +18,10 @@ concurrency: jobs: build: name: "Release build on macOS" - runs-on: macos-26 + runs-on: macos-15 steps: - uses: actions/checkout@v6 + - run: sudo xcode-select -s /Applications/Xcode_26.0-beta.app - uses: jdx/mise-action@v3 - name: Run run: mise run build-spm @@ -36,9 +37,10 @@ jobs: test: name: "Test on macOS" - runs-on: macos-26 + runs-on: macos-15 steps: - uses: actions/checkout@v6 + - run: sudo xcode-select -s /Applications/Xcode_26.0-beta.app - uses: jdx/mise-action@v3 - name: Run run: mise run test-spm @@ -84,9 +86,10 @@ jobs: lint: name: Lint - runs-on: macos-26 + runs-on: macos-15 steps: - uses: actions/checkout@v6 + - run: sudo xcode-select -s /Applications/Xcode_26.0-beta.app - uses: jdx/mise-action@v3 - name: Run run: mise run lint From 8d4edabd8879c92f35d0dcec5bf19327707ff1c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Pi=C3=B1era=20Buend=C3=ADa?= Date: Thu, 19 Mar 2026 12:10:09 +0100 Subject: [PATCH 18/19] fix: restore CI on macOS and CodeQL --- .github/workflows/codeql.yml | 53 ++++++ .github/workflows/file-system.yml | 6 +- Sources/FileSystem/FileSystem.swift | 252 ++++++++++++++-------------- 3 files changed, 184 insertions(+), 127 deletions(-) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..58a1177 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,53 @@ +name: CodeQL + +on: + push: + branches: + - main + pull_request: + branches: + - main + schedule: + - cron: "23 4 * * 1" + +permissions: + actions: read + contents: read + security-events: write + +jobs: + analyze-actions: + name: Analyze (actions) + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + steps: + - uses: actions/checkout@v6 + - uses: github/codeql-action/init@v4 + with: + languages: actions + build-mode: none + - uses: github/codeql-action/analyze@v4 + with: + category: /language:actions + + analyze-swift: + name: Analyze (swift) + runs-on: macos-15 + permissions: + actions: read + contents: read + security-events: write + steps: + - uses: actions/checkout@v6 + - run: sudo xcode-select -s /Applications/Xcode_26.0.app + - uses: github/codeql-action/init@v4 + with: + languages: swift + build-mode: manual + - run: swift build --configuration release + - uses: github/codeql-action/analyze@v4 + with: + category: /language:swift diff --git a/.github/workflows/file-system.yml b/.github/workflows/file-system.yml index a68a8bf..4704760 100644 --- a/.github/workflows/file-system.yml +++ b/.github/workflows/file-system.yml @@ -21,7 +21,7 @@ jobs: runs-on: macos-15 steps: - uses: actions/checkout@v6 - - run: sudo xcode-select -s /Applications/Xcode_26.0-beta.app + - run: sudo xcode-select -s /Applications/Xcode_26.0.app - uses: jdx/mise-action@v3 - name: Run run: mise run build-spm @@ -40,7 +40,7 @@ jobs: runs-on: macos-15 steps: - uses: actions/checkout@v6 - - run: sudo xcode-select -s /Applications/Xcode_26.0-beta.app + - run: sudo xcode-select -s /Applications/Xcode_26.0.app - uses: jdx/mise-action@v3 - name: Run run: mise run test-spm @@ -89,7 +89,7 @@ jobs: runs-on: macos-15 steps: - uses: actions/checkout@v6 - - run: sudo xcode-select -s /Applications/Xcode_26.0-beta.app + - run: sudo xcode-select -s /Applications/Xcode_26.0.app - uses: jdx/mise-action@v3 - name: Run run: mise run lint diff --git a/Sources/FileSystem/FileSystem.swift b/Sources/FileSystem/FileSystem.swift index ea87442..6e0aee6 100644 --- a/Sources/FileSystem/FileSystem.swift +++ b/Sources/FileSystem/FileSystem.swift @@ -359,8 +359,8 @@ public protocol FileSysteming: Sendable { // MARK: - FileSystem public struct FileSystem: FileSysteming, Sendable { - fileprivate let logger: Logger? - fileprivate let environmentVariables: [String: String] + private let logger: Logger? + private let environmentVariables: [String: String] public init(environmentVariables: [String: String] = ProcessInfo.processInfo.environment, logger: Logger? = nil) { self.environmentVariables = environmentVariables @@ -645,123 +645,6 @@ public struct FileSystem: FileSysteming, Sendable { } } - @available( - *, - deprecated, - renamed: "fileMetadata", - message: "Read the file size from the metadata, which contains other attributes" - ) - public func fileSizeInBytes(at path: AbsolutePath) async throws -> Int64? { - logger?.debug("Getting the size in bytes of file at path \(path.pathString).") - return try await fileMetadata(at: path)?.size - } - - public func fileMetadata(at path: AbsolutePath) async throws -> FileMetadata? { - logger?.debug("Getting the metadata of file at path \(path.pathString).") - let fp = try filePath(path) - guard await File.System.Stat.exists(at: fp) else { return nil } - let info = try await File.System.Stat.info(at: fp) - let modificationTime = info.timestamps.modificationTime - let seconds = TimeInterval(modificationTime.secondsSinceEpoch) - let nanoseconds = TimeInterval(modificationTime.totalNanoseconds) / 1_000_000_000 - let modificationDate = Date(timeIntervalSince1970: seconds + nanoseconds) - return FileMetadata(size: info.size, lastModificationDate: modificationDate) - } - - public func setFileTimes( - of path: AbsolutePath, - lastAccessDate: Date?, - lastModificationDate: Date? - ) async throws { - logger?.debug("Setting file times at path \(path.pathString).") - #if os(Windows) - let handle = path.pathString - .replacingOccurrences(of: "/", with: "\\") - .withCString(encodedAs: UTF16.self) { wpath in - CreateFileW( - wpath, - DWORD(FILE_WRITE_ATTRIBUTES), - DWORD(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE), - nil, - DWORD(OPEN_EXISTING), - DWORD(FILE_ATTRIBUTE_NORMAL), - nil - ) - } - guard let handle, handle != INVALID_HANDLE_VALUE else { - throw NSError(domain: "WinSDK", code: Int(GetLastError())) - } - defer { CloseHandle(handle) } - - let accessTime = lastAccessDate.map(Self.windowsFileTime(from:)) - let modificationTime = lastModificationDate.map(Self.windowsFileTime(from:)) - var success = true - if var accessTime, var modificationTime { - success = SetFileTime(handle, nil, &accessTime, &modificationTime) - } else if var accessTime { - success = SetFileTime(handle, nil, &accessTime, nil) - } else if var modificationTime { - success = SetFileTime(handle, nil, nil, &modificationTime) - } else { - return - } - guard success else { throw NSError(domain: "WinSDK", code: Int(GetLastError())) } - #else - try Self.updateFileTimes( - path: path.pathString, - lastAccessDate: lastAccessDate, - lastModificationDate: lastModificationDate - ) - #endif - } - - public func locateTraversingUp(from: AbsolutePath, relativePath: RelativePath) async throws -> AbsolutePath? { - logger?.debug("Locating the relative path \(relativePath.pathString) by traversing up from \(from.pathString).") - let path = from.appending(relativePath) - if try await exists(path) { - return path - } - if from == .root { return nil } - return try await locateTraversingUp(from: from.parentDirectory, relativePath: relativePath) - } - - public func createSymbolicLink(from: AbsolutePath, to: AbsolutePath) async throws { - try await createSymbolicLink(fromPathString: from.pathString, toPathString: to.pathString) - } - - public func createSymbolicLink(from: AbsolutePath, to: RelativePath) async throws { - try await createSymbolicLink(fromPathString: from.pathString, toPathString: to.pathString) - } - - private func createSymbolicLink(fromPathString: String, toPathString: String) async throws { - logger?.debug("Creating symbolic link from \(fromPathString) to \(toPathString).") - try await File.System.Link.Symbolic.create( - at: try File.Path(fromPathString), - pointingTo: try File.Path(toPathString) - ) - } - - public func resolveSymbolicLink(_ symlinkPath: AbsolutePath) async throws -> AbsolutePath { - logger?.debug("Resolving symbolink link at path \(symlinkPath.pathString).") - let fp = try filePath(symlinkPath) - guard await File.System.Stat.exists(at: fp) else { - throw FileSystemError.absentSymbolicLink(symlinkPath) - } - do { - let targetPath = try await File.System.Link.Read.Target.target(of: fp) - if targetPath.isAbsolute { - return try absolutePath(targetPath) - } else { - return AbsolutePath( - symlinkPath.parentDirectory, - try RelativePath(validating: String(describing: targetPath)) - ) - } - } catch { - return symlinkPath - } - } - #if !os(Windows) public func zipFileOrDirectoryContent(at path: Path.AbsolutePath, to: Path.AbsolutePath) async throws { logger?.debug("Zipping the file or contents of directory at path \(path.pathString) into \(to.pathString)") @@ -860,11 +743,11 @@ extension FileSystem { // MARK: - Path conversion helpers extension FileSystem { - fileprivate func filePath(_ path: AbsolutePath) throws -> File.Path { + private func filePath(_ path: AbsolutePath) throws -> File.Path { try File.Path(path.pathString) } - fileprivate func absolutePath(_ path: File.Path) throws -> AbsolutePath { + private func absolutePath(_ path: File.Path) throws -> AbsolutePath { try AbsolutePath(validating: String(describing: path)) } } @@ -875,7 +758,7 @@ extension FileSystem { /// Removes a file, symlink, or directory (recursively) using lstat to avoid /// following symlinks. This works around a bug in swift-file-system's Delete /// which uses stat() and fails on symlinks whose targets don't exist. - fileprivate func removeItem(at path: File.Path) throws { + private func removeItem(at path: File.Path) throws { let info: File.System.Metadata.Info do { info = try File.System.Stat.lstatInfo(at: path) @@ -923,7 +806,7 @@ extension FileSystem { // MARK: - File I/O helpers extension FileSystem { - fileprivate func writeFileBytes(_ data: Data, to path: AbsolutePath) async throws { + private func writeFileBytes(_ data: Data, to path: AbsolutePath) async throws { try await File.System.Write.Atomic.write( [UInt8](data), to: try filePath(path), @@ -931,7 +814,7 @@ extension FileSystem { ) } - fileprivate func copyItem(from source: File.Path, to destination: File.Path) async throws { + private func copyItem(from source: File.Path, to destination: File.Path) async throws { guard !(await File.System.Stat.exists(at: destination)) else { throw CocoaError(.fileWriteFileExists, userInfo: [NSFilePathErrorKey: String(describing: destination)]) } @@ -959,6 +842,127 @@ extension FileSystem { } } +// MARK: - Metadata and symlink helpers + +extension FileSystem { + @available( + *, + deprecated, + renamed: "fileMetadata", + message: "Read the file size from the metadata, which contains other attributes" + ) + public func fileSizeInBytes(at path: AbsolutePath) async throws -> Int64? { + logger?.debug("Getting the size in bytes of file at path \(path.pathString).") + return try await fileMetadata(at: path)?.size + } + + public func fileMetadata(at path: AbsolutePath) async throws -> FileMetadata? { + logger?.debug("Getting the metadata of file at path \(path.pathString).") + let fp = try filePath(path) + guard await File.System.Stat.exists(at: fp) else { return nil } + let info = try await File.System.Stat.info(at: fp) + let modificationTime = info.timestamps.modificationTime + let seconds = TimeInterval(modificationTime.secondsSinceEpoch) + let nanoseconds = TimeInterval(modificationTime.totalNanoseconds) / 1_000_000_000 + let modificationDate = Date(timeIntervalSince1970: seconds + nanoseconds) + return FileMetadata(size: info.size, lastModificationDate: modificationDate) + } + + public func setFileTimes( + of path: AbsolutePath, + lastAccessDate: Date?, + lastModificationDate: Date? + ) async throws { + logger?.debug("Setting file times at path \(path.pathString).") + #if os(Windows) + let handle = path.pathString + .replacingOccurrences(of: "/", with: "\\") + .withCString(encodedAs: UTF16.self) { wpath in + CreateFileW( + wpath, + DWORD(FILE_WRITE_ATTRIBUTES), + DWORD(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE), + nil, + DWORD(OPEN_EXISTING), + DWORD(FILE_ATTRIBUTE_NORMAL), + nil + ) + } + guard let handle, handle != INVALID_HANDLE_VALUE else { + throw NSError(domain: "WinSDK", code: Int(GetLastError())) + } + defer { CloseHandle(handle) } + + let accessTime = lastAccessDate.map(Self.windowsFileTime(from:)) + let modificationTime = lastModificationDate.map(Self.windowsFileTime(from:)) + var success = true + if var accessTime, var modificationTime { + success = SetFileTime(handle, nil, &accessTime, &modificationTime) + } else if var accessTime { + success = SetFileTime(handle, nil, &accessTime, nil) + } else if var modificationTime { + success = SetFileTime(handle, nil, nil, &modificationTime) + } else { + return + } + guard success else { throw NSError(domain: "WinSDK", code: Int(GetLastError())) } + #else + try Self.updateFileTimes( + path: path.pathString, + lastAccessDate: lastAccessDate, + lastModificationDate: lastModificationDate + ) + #endif + } + + public func locateTraversingUp(from: AbsolutePath, relativePath: RelativePath) async throws -> AbsolutePath? { + logger?.debug("Locating the relative path \(relativePath.pathString) by traversing up from \(from.pathString).") + let path = from.appending(relativePath) + if try await exists(path) { + return path + } + if from == .root { return nil } + return try await locateTraversingUp(from: from.parentDirectory, relativePath: relativePath) + } + + public func createSymbolicLink(from: AbsolutePath, to: AbsolutePath) async throws { + try await createSymbolicLink(fromPathString: from.pathString, toPathString: to.pathString) + } + + public func createSymbolicLink(from: AbsolutePath, to: RelativePath) async throws { + try await createSymbolicLink(fromPathString: from.pathString, toPathString: to.pathString) + } + + public func resolveSymbolicLink(_ symlinkPath: AbsolutePath) async throws -> AbsolutePath { + logger?.debug("Resolving symbolink link at path \(symlinkPath.pathString).") + let fp = try filePath(symlinkPath) + guard await File.System.Stat.exists(at: fp) else { + throw FileSystemError.absentSymbolicLink(symlinkPath) + } + do { + let targetPath = try await File.System.Link.Read.Target.target(of: fp) + if targetPath.isAbsolute { + return try absolutePath(targetPath) + } else { + return AbsolutePath( + symlinkPath.parentDirectory, + try RelativePath(validating: String(describing: targetPath)) + ) + } + } catch { + return symlinkPath + } + } + + private func createSymbolicLink(fromPathString: String, toPathString: String) async throws { + logger?.debug("Creating symbolic link from \(fromPathString) to \(toPathString).") + try await File.System.Link.Symbolic.create( + at: try File.Path(fromPathString), + pointingTo: try File.Path(toPathString) + ) + } +} + // MARK: - File time helpers (raw system calls, since StandardTime.Time is @_spi(Internal)) #if !os(Windows) From de3de300546ce0052927e3e15fde32f994f195c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Pi=C3=B1era=20Buend=C3=ADa?= Date: Thu, 19 Mar 2026 12:25:58 +0100 Subject: [PATCH 19/19] fix: run macOS tests on macOS 26 --- .github/workflows/file-system.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/file-system.yml b/.github/workflows/file-system.yml index 4704760..0618c68 100644 --- a/.github/workflows/file-system.yml +++ b/.github/workflows/file-system.yml @@ -37,10 +37,9 @@ jobs: test: name: "Test on macOS" - runs-on: macos-15 + runs-on: macos-26 steps: - uses: actions/checkout@v6 - - run: sudo xcode-select -s /Applications/Xcode_26.0.app - uses: jdx/mise-action@v3 - name: Run run: mise run test-spm