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 8aab1ef..0618c68 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_16.3.app + - run: sudo xcode-select -s /Applications/Xcode_26.0.app - uses: jdx/mise-action@v3 - name: Run run: mise run build-spm @@ -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_16.3.app - uses: jdx/mise-action@v3 - name: Run run: mise run test-spm @@ -63,8 +62,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,8 +77,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: Test run: swift test @@ -89,7 +88,7 @@ jobs: runs-on: macos-15 steps: - uses: actions/checkout@v6 - - run: sudo xcode-select -s /Applications/Xcode_16.3.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/.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" 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..3086eec 100644 --- a/Package.swift +++ b/Package.swift @@ -1,13 +1,11 @@ -// 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] = [] 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)), ] @@ -82,10 +76,7 @@ let package = Package( ] ), .target( - name: "Glob", - swiftSettings: [ - .enableExperimentalFeature("StrictConcurrency"), - ] + name: "Glob" ), .testTarget( name: "GlobTests", diff --git a/Sources/FileSystem/FileSystem.swift b/Sources/FileSystem/FileSystem.swift index df1131a..6e0aee6 100644 --- a/Sources/FileSystem/FileSystem.swift +++ b/Sources/FileSystem/FileSystem.swift @@ -1,14 +1,20 @@ -#if os(Windows) - import WinSDK -#else - import _NIOFileSystem - import NIOCore -#endif +import File_System +import File_System_Primitives import Foundation import Glob import Logging import Path +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#elseif canImport(Musl) + import Musl +#elseif os(Windows) + import WinSDK +#endif + #if !os(Windows) import ZIPFoundation #endif @@ -70,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 } @@ -348,18 +354,13 @@ 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] + private let logger: Logger? + private let environmentVariables: [String: String] public init(environmentVariables: [String: String] = ProcessInfo.processInfo.environment, logger: Logger? = nil) { self.environmentVariables = environmentVariables @@ -367,40 +368,20 @@ 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 + 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 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).") - #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 filePath(path)) } public func exists(_ path: AbsolutePath, isDirectory: Bool) async throws -> Bool { @@ -409,66 +390,50 @@ 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 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 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 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 await Task { - try FileManager.default.removeItem(atPath: path.pathString) - } - .value + let fp = try filePath(path) + try removeItem(at: fp) } 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)") 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 filePath(temporaryDirectory), + options: .init(createIntermediates: true) ) return temporaryDirectory } @@ -491,26 +456,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 filePath(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 filePath(to)) } public func makeDirectory(at: Path.AbsolutePath) async throws { @@ -519,79 +469,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).") } - #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 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).") - } - #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 + 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 { @@ -629,14 +526,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 writeFileBytes(data, to: path) } public func readPlistFile(at path: Path.AbsolutePath) async throws -> T where T: Decodable { @@ -670,13 +560,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 writeFileBytes(plistData, to: path) } public func readJSONFile(at path: Path.AbsolutePath) async throws -> T where T: Decodable { @@ -709,47 +593,35 @@ 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 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 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 removeItem(at: destinationPath) + } + 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) } - #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: try filePath(to)) } public func runInTemporaryDirectory( @@ -773,163 +645,22 @@ 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).") - #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 - } - - 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 - } - - public func setFileTimes( - of path: AbsolutePath, - lastAccessDate: Date?, - 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 - 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) - } - #endif - } - - #if !os(Windows) - private static func dateToTimespec(_ date: Date) -> _NIOFileSystem.FileInfo.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) - } - #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).") - #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 - } - - public func resolveSymbolicLink(_ symlinkPath: AbsolutePath) async throws -> AbsolutePath { - logger?.debug("Resolving symbolink link at path \(symlinkPath.pathString).") - if !(try await exists(symlinkPath)) { - 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) - } else { - return AbsolutePath(symlinkPath.parentDirectory, try RelativePath(validating: path.string)) - } - #endif - } - #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 await createArchive( + 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 extractArchive( + at: URL(fileURLWithPath: zipPath.pathString), + to: URL(fileURLWithPath: to.pathString) + ) } #endif @@ -954,10 +685,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) }, @@ -968,31 +697,30 @@ 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() } } +// 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) } } } -#if !os(Windows) - extension FilePath { - fileprivate var path: AbsolutePath { - try! AbsolutePath(validating: string) // swiftlint:disable:this force_try - } - } -#endif +// MARK: - Convenience overloads 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. public func runInTemporaryDirectory( _ action: @Sendable (_ temporaryDirectory: AbsolutePath) async throws -> T ) async throws -> T { @@ -1011,3 +739,378 @@ extension FileSystem { try await writeAsJSON(item, at: path, encoder: JSONEncoder(), options: options) } } + +// MARK: - Path conversion helpers + +extension FileSystem { + private func filePath(_ path: AbsolutePath) throws -> File.Path { + try File.Path(path.pathString) + } + + private func absolutePath(_ path: File.Path) throws -> AbsolutePath { + try AbsolutePath(validating: String(describing: path)) + } +} + +// 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. + private 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 rmdirSuccess = String(describing: path) + .replacingOccurrences(of: "/", with: "\\") + .withCString(encodedAs: UTF16.self) { RemoveDirectoryW($0) } + guard rmdirSuccess 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 deleteSuccess = String(describing: path) + .replacingOccurrences(of: "/", with: "\\") + .withCString(encodedAs: UTF16.self) { DeleteFileW($0) } + guard deleteSuccess 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 { + private 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 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)]) + } + + let metadata = try File.System.Stat.lstatInfo(at: source) + switch metadata.type { + case .directory: + 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 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) + ) + } + } +} + +// 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) + 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)) + } + + 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)) + } + } + + 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) + } + + 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) + } + + fileprivate static func accessTimespec(from info: stat) -> timespec { + #if canImport(Darwin) + info.st_atimespec + #else + info.st_atim + #endif + } + + fileprivate static func modificationTimespec(from info: stat) -> timespec { + #if canImport(Darwin) + info.st_mtimespec + #else + info.st_mtim + #endif + } + } +#else + extension FileSystem { + fileprivate static 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) + ) + } + } +#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]) + } + + 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 + } + } + } + + fileprivate func descendantRelativePaths(of root: AbsolutePath) async throws -> [String] { + try await descendantRelativePaths(of: root, prefix: "") + } + + 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 diff --git a/Sources/Glob/GlobSearch.swift b/Sources/Glob/GlobSearch.swift index 1681c73..4b61ce3 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,19 @@ 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 +166,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 +225,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,9 +246,123 @@ 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 = windowsDirectoryEntryName(from: findData) + guard entry != ".", entry != ".." else { continue } + entries.append(entry) + } while windowsSucceeded(FindNextFileW(handle, &findData)) + + 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 { + 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) + private func windowsSucceeded(_ result: Bool) -> Bool { + result + } + + private func windowsSucceeded(_ result: some BinaryInteger) -> Bool { + result != 0 + } + + 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) + } + } + } +#endif + 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) 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