diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..a010156 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,44 @@ +{ + "name": "PathKit", + "image": "swift:5.8", + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": "false", + "username": "vscode", + "userUid": "1000", + "userGid": "1000", + "upgradePackages": "false" + }, + "ghcr.io/devcontainers/features/git:1": { + "version": "os-provided", + "ppa": "false" + } + }, + "runArgs": [ + "--cap-add=SYS_PTRACE", + "--security-opt", + "seccomp=unconfined" + ], + // Configure tool-specific properties. + "customizations": { + // Configure properties specific to VS Code. + "vscode": { + // Set *default* container specific settings.json values on container create. + "settings": { + "lldb.library": "/usr/lib/liblldb.so" + }, + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "sswg.swift-lang" + ] + } + }, + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": "swift --version", + + // Set `remoteUser` to `root` to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. + "remoteUser": "vscode" +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1082912 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: ci + +on: + push: + branches: + - main + pull_request: + branches: + - '*' + workflow_dispatch: + +jobs: + lint: + runs-on: macos-14 + environment: default + steps: + - uses: actions/checkout@v3 + - name: SwiftFormat version + run: swiftformat --version + - name: Format lint + run: swiftformat --lint . + - name: Install SwiftLint + run: brew install swiftlint + - name: SwiftLint version + run: swiftlint --version + - name: Lint + run: swiftlint lint --quiet + macos-test: + runs-on: macos-14 + environment: default + strategy: + matrix: + xcode: ['14.3.1', '15.2'] + # Swift: 5.8.1 , 5.9.2 + steps: + - uses: actions/checkout@v3 + - name: Select Xcode ${{ matrix.xcode }} + run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app + - name: Run Tests + run: TEMP_DIR=${{ runner.temp }} swift test --enable-code-coverage + linux-test: + runs-on: ubuntu-latest + environment: default + + steps: + - uses: actions/checkout@v3 + - name: Run Tests + run: TEMP_DIR=${{ runner.temp }} swift test diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml deleted file mode 100644 index f93f4b8..0000000 --- a/.github/workflows/main.yaml +++ /dev/null @@ -1,7 +0,0 @@ -on: push -jobs: - test: - runs-on: macos-latest - steps: - - uses: actions/checkout@v2 - - run: swift test diff --git a/.gitignore b/.gitignore index 0700292..fe55e8f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ -.build/ -Packages/ -Package.resolved +.DS_Store +/.build +/.swiftpm +/*.xcodeproj +xcuserdata/ +/.default.profraw \ No newline at end of file diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..ecda89d --- /dev/null +++ b/.swiftformat @@ -0,0 +1,8 @@ +--extensionacl on-declarations +--redundanttype explicit +--swiftversion 5.8 +--maxwidth 120 +--header "{file}\nPathKit\n\nCopyright (c) 2014, Kyle Fuller\nAll rights reserved.\nVersion 1.0.1\n\nCopyright © {year} MFB Technologies, Inc. All rights reserved.\nAfter Version 1.0.1\n\nThis source code is licensed under the BSD-2-Clause License found in the\nLICENSE file in the root directory of this source tree."" +--allman false +--wraparguments before-first +--wrapcollections before-first \ No newline at end of file diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..f015c0c --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,17 @@ +disabled_rules: + - multiple_closures_with_trailing_closure # by SwiftUI + - trailing_comma # conflicts with SwiftFormat + - opening_brace # conflicts with SwiftFormat +excluded: # paths to ignore during linting. Takes precedence over `included`. + - Carthage + - Pods + - .build/* + - output + - ./**/*Tests/* + - Previews +identifier_name: + allowed_symbols: "_" +type_name: + allowed_symbols: "_" + excluded: + - ID diff --git a/LICENSE b/LICENSE index 6859a92..a274e6d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,9 @@ Copyright (c) 2014, Kyle Fuller All rights reserved. +Version 1.0.1 + +Copyright © 2024 MFB Technologies, Inc. All rights reserved. +After Version 1.0.1 Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: @@ -20,4 +24,3 @@ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - diff --git a/Package.swift b/Package.swift index 791d8ca..da1f812 100644 --- a/Package.swift +++ b/Package.swift @@ -1,16 +1,34 @@ -// swift-tools-version:4.2 +// swift-tools-version:5.8 import PackageDescription let package = Package( - name: "PathKit", - products: [ - .library(name: "PathKit", targets: ["PathKit"]), - ], - dependencies: [ - .package(url:"https://github.com/kylef/Spectre.git", .upToNextMinor(from:"0.10.0")) - ], - targets: [ - .target(name: "PathKit", dependencies: [], path: "Sources"), - .testTarget(name: "PathKitTests", dependencies: ["PathKit", "Spectre"], path:"Tests/PathKitTests") - ] + name: "PathKit", + platforms: [.iOS(.v12), .macOS(.v10_13), .watchOS(.v4), .tvOS(.v12), .macCatalyst(.v13)], + products: [ + .library(name: "PathKit", targets: ["PathKit"]), + ], + targets: [ + .target( + name: "PathKit", + swiftSettings: .swiftSix + ), + .testTarget( + name: "PathKitTests", + dependencies: [ + "PathKit", + ], + swiftSettings: .swiftSix + ), + ] ) + +extension [SwiftSetting] { + static let swiftSix: [SwiftSetting] = [ + .enableUpcomingFeature("BareSlashRegexLiterals"), + .enableUpcomingFeature("ConciseMagicFile"), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("ForwardTrailingClosures"), + .enableUpcomingFeature("ImplicitOpenExistentials"), + .enableUpcomingFeature("StrictConcurrency"), + ] +} diff --git a/PathKit.podspec b/PathKit.podspec deleted file mode 100644 index 14e9728..0000000 --- a/PathKit.podspec +++ /dev/null @@ -1,15 +0,0 @@ -Pod::Spec.new do |spec| - spec.name = 'PathKit' - spec.version = '1.0.1' - spec.summary = 'Effortless path operations in Swift.' - spec.homepage = 'https://github.com/kylef/PathKit' - spec.license = { :type => 'BSD', :file => 'LICENSE' } - spec.author = { 'Kyle Fuller' => 'kyle@fuller.li' } - spec.social_media_url = 'http://twitter.com/kylefuller' - spec.source = { :git => 'https://github.com/kylef/PathKit.git', :tag => spec.version } - spec.source_files = 'Sources/PathKit.swift' - spec.ios.deployment_target = '8.0' - spec.osx.deployment_target = '10.9' - spec.tvos.deployment_target = '9.0' - spec.requires_arc = true -end diff --git a/README.md b/README.md index e7ab775..f7090a7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # PathKit -[![Build Status](https://travis-ci.org/kylef/PathKit.svg)](https://travis-ci.org/kylef/PathKit) +Fork of [https://github.com/kylef/PathKit](https://github.com/kylef/PathKit) Effortless path operations in Swift. @@ -105,13 +105,6 @@ path.write("Hello World!") let paths = Path.glob("*.swift") ``` -### Contact - -Kyle Fuller - -- https://fuller.li -- https://twitter.com/kylefuller - ### License PathKit is licensed under the [BSD License](LICENSE). diff --git a/Sources/PathKit.swift b/Sources/PathKit.swift deleted file mode 100644 index 3cf558f..0000000 --- a/Sources/PathKit.swift +++ /dev/null @@ -1,812 +0,0 @@ -// PathKit - Effortless path operations - -#if os(Linux) -import Glibc - -let system_glob = Glibc.glob -#else -import Darwin - -let system_glob = Darwin.glob -#endif - -import Foundation - - -/// Represents a filesystem path. -public struct Path { - /// The character used by the OS to separate two path elements - public static let separator = "/" - - /// The underlying string representation - internal let path: String - - internal static let fileManager = FileManager.default - - internal let fileSystemInfo: FileSystemInfo - - // MARK: Init - - public init() { - self.init("") - } - - /// Create a Path from a given String - public init(_ path: String) { - self.init(path, fileSystemInfo: DefaultFileSystemInfo()) - } - - internal init(_ path: String, fileSystemInfo: FileSystemInfo) { - self.path = path - self.fileSystemInfo = fileSystemInfo - } - - internal init(fileSystemInfo: FileSystemInfo) { - self.init("", fileSystemInfo: fileSystemInfo) - } - - /// Create a Path by joining multiple path components together - public init(components: S) where S.Iterator.Element == String { - let path: String - if components.isEmpty { - path = "." - } else if components.first == Path.separator && components.count > 1 { - let p = components.joined(separator: Path.separator) - path = String(p[p.index(after: p.startIndex)...]) - } else { - path = components.joined(separator: Path.separator) - } - self.init(path) - } -} - - -// MARK: StringLiteralConvertible - -extension Path : ExpressibleByStringLiteral { - public typealias ExtendedGraphemeClusterLiteralType = StringLiteralType - public typealias UnicodeScalarLiteralType = StringLiteralType - - public init(extendedGraphemeClusterLiteral path: StringLiteralType) { - self.init(stringLiteral: path) - } - - public init(unicodeScalarLiteral path: StringLiteralType) { - self.init(stringLiteral: path) - } - - public init(stringLiteral value: StringLiteralType) { - self.init(value) - } -} - - -// MARK: CustomStringConvertible - -extension Path : CustomStringConvertible { - public var description: String { - return self.path - } -} - - -// MARK: Conversion - -extension Path { - public var string: String { - return self.path - } - - public var url: URL { - return URL(fileURLWithPath: path) - } -} - - -// MARK: Hashable - -extension Path : Hashable { - public func hash(into hasher: inout Hasher) { - hasher.combine(self.path.hashValue) - } -} - - -// MARK: Path Info - -extension Path { - /// Test whether a path is absolute. - /// - /// - Returns: `true` iff the path begins with a slash - /// - public var isAbsolute: Bool { - return path.hasPrefix(Path.separator) - } - - /// Test whether a path is relative. - /// - /// - Returns: `true` iff a path is relative (not absolute) - /// - public var isRelative: Bool { - return !isAbsolute - } - - /// Concatenates relative paths to the current directory and derives the normalized path - /// - /// - Returns: the absolute path in the actual filesystem - /// - public func absolute() -> Path { - if isAbsolute { - return normalize() - } - - let expandedPath = Path(NSString(string: self.path).expandingTildeInPath) - if expandedPath.isAbsolute { - return expandedPath.normalize() - } - - return (Path.current + self).normalize() - } - - /// Normalizes the path, this cleans up redundant ".." and ".", double slashes - /// and resolves "~". - /// - /// - Returns: a new path made by removing extraneous path components from the underlying String - /// representation. - /// - public func normalize() -> Path { - return Path(NSString(string: self.path).standardizingPath) - } - - /// De-normalizes the path, by replacing the current user home directory with "~". - /// - /// - Returns: a new path made by removing extraneous path components from the underlying String - /// representation. - /// - public func abbreviate() -> Path { - let rangeOptions: String.CompareOptions = fileSystemInfo.isFSCaseSensitiveAt(path: self) ? - [.anchored] : [.anchored, .caseInsensitive] - let home = Path.home.string - guard let homeRange = self.path.range(of: home, options: rangeOptions) else { return self } - let withoutHome = Path(self.path.replacingCharacters(in: homeRange, with: "")) - - if withoutHome.path.isEmpty || withoutHome.path == Path.separator { - return Path("~") - } else if withoutHome.isAbsolute { - return Path("~" + withoutHome.path) - } else { - return Path("~") + withoutHome.path - } - } - - /// Returns the path of the item pointed to by a symbolic link. - /// - /// - Returns: the path of directory or file to which the symbolic link refers - /// - public func symlinkDestination() throws -> Path { - let symlinkDestination = try Path.fileManager.destinationOfSymbolicLink(atPath: path) - let symlinkPath = Path(symlinkDestination) - if symlinkPath.isRelative { - return self + ".." + symlinkPath - } else { - return symlinkPath - } - } -} - -internal protocol FileSystemInfo { - func isFSCaseSensitiveAt(path: Path) -> Bool -} - -internal struct DefaultFileSystemInfo: FileSystemInfo { - func isFSCaseSensitiveAt(path: Path) -> Bool { - #if os(Linux) - // URL resourceValues(forKeys:) is not supported on non-darwin platforms... - // But we can (fairly?) safely assume for now that the Linux FS is case sensitive. - // TODO: refactor when/if resourceValues is available, or look into using something - // like stat or pathconf to determine if the mountpoint is case sensitive. - return true - #else - var isCaseSensitive = false - // Calling resourceValues will fail if the path does not exist on the filesystem, which - // makes sense, but means we can only guarantee the return value is correct if the - // path actually exists. - if let resourceValues = try? path.url.resourceValues(forKeys: [.volumeSupportsCaseSensitiveNamesKey]) { - isCaseSensitive = resourceValues.volumeSupportsCaseSensitiveNames ?? isCaseSensitive - } - return isCaseSensitive - #endif - } -} - -// MARK: Path Components - -extension Path { - /// The last path component - /// - /// - Returns: the last path component - /// - public var lastComponent: String { - return NSString(string: path).lastPathComponent - } - - /// The last path component without file extension - /// - /// - Note: This returns "." for ".." on Linux, and ".." on Apple platforms. - /// - /// - Returns: the last path component without file extension - /// - public var lastComponentWithoutExtension: String { - return NSString(string: lastComponent).deletingPathExtension - } - - /// Splits the string representation on the directory separator. - /// Absolute paths remain the leading slash as first component. - /// - /// - Returns: all path components - /// - public var components: [String] { - return NSString(string: path).pathComponents - } - - /// The file extension behind the last dot of the last component. - /// - /// - Returns: the file extension - /// - public var `extension`: String? { - let pathExtension = NSString(string: path).pathExtension - if pathExtension.isEmpty { - return nil - } - - return pathExtension - } -} - - -// MARK: File Info - -extension Path { - /// Test whether a file or directory exists at a specified path - /// - /// - Returns: `false` iff the path doesn't exist on disk or its existence could not be - /// determined - /// - public var exists: Bool { - return Path.fileManager.fileExists(atPath: self.path) - } - - /// Test whether a path is a directory. - /// - /// - Returns: `true` if the path is a directory or a symbolic link that points to a directory; - /// `false` if the path is not a directory or the path doesn't exist on disk or its existence - /// could not be determined - /// - public var isDirectory: Bool { - var directory = ObjCBool(false) - guard Path.fileManager.fileExists(atPath: normalize().path, isDirectory: &directory) else { - return false - } - return directory.boolValue - } - - /// Test whether a path is a regular file. - /// - /// - Returns: `true` if the path is neither a directory nor a symbolic link that points to a - /// directory; `false` if the path is a directory or a symbolic link that points to a - /// directory or the path doesn't exist on disk or its existence - /// could not be determined - /// - public var isFile: Bool { - var directory = ObjCBool(false) - guard Path.fileManager.fileExists(atPath: normalize().path, isDirectory: &directory) else { - return false - } - return !directory.boolValue - } - - /// Test whether a path is a symbolic link. - /// - /// - Returns: `true` if the path is a symbolic link; `false` if the path doesn't exist on disk - /// or its existence could not be determined - /// - public var isSymlink: Bool { - do { - let _ = try Path.fileManager.destinationOfSymbolicLink(atPath: path) - return true - } catch { - return false - } - } - - /// Test whether a path is readable - /// - /// - Returns: `true` if the current process has read privileges for the file at path; - /// otherwise `false` if the process does not have read privileges or the existence of the - /// file could not be determined. - /// - public var isReadable: Bool { - return Path.fileManager.isReadableFile(atPath: self.path) - } - - /// Test whether a path is writeable - /// - /// - Returns: `true` if the current process has write privileges for the file at path; - /// otherwise `false` if the process does not have write privileges or the existence of the - /// file could not be determined. - /// - public var isWritable: Bool { - return Path.fileManager.isWritableFile(atPath: self.path) - } - - /// Test whether a path is executable - /// - /// - Returns: `true` if the current process has execute privileges for the file at path; - /// otherwise `false` if the process does not have execute privileges or the existence of the - /// file could not be determined. - /// - public var isExecutable: Bool { - return Path.fileManager.isExecutableFile(atPath: self.path) - } - - /// Test whether a path is deletable - /// - /// - Returns: `true` if the current process has delete privileges for the file at path; - /// otherwise `false` if the process does not have delete privileges or the existence of the - /// file could not be determined. - /// - public var isDeletable: Bool { - return Path.fileManager.isDeletableFile(atPath: self.path) - } -} - - -// MARK: File Manipulation - -extension Path { - /// Create the directory. - /// - /// - Note: This method fails if any of the intermediate parent directories does not exist. - /// This method also fails if any of the intermediate path elements corresponds to a file and - /// not a directory. - /// - public func mkdir() throws -> () { - try Path.fileManager.createDirectory(atPath: self.path, withIntermediateDirectories: false, attributes: nil) - } - - /// Create the directory and any intermediate parent directories that do not exist. - /// - /// - Note: This method fails if any of the intermediate path elements corresponds to a file and - /// not a directory. - /// - public func mkpath() throws -> () { - try Path.fileManager.createDirectory(atPath: self.path, withIntermediateDirectories: true, attributes: nil) - } - - /// Delete the file or directory. - /// - /// - Note: If the path specifies a directory, the contents of that directory are recursively - /// removed. - /// - public func delete() throws -> () { - try Path.fileManager.removeItem(atPath: self.path) - } - - /// Move the file or directory to a new location synchronously. - /// - /// - Parameter destination: The new path. This path must include the name of the file or - /// directory in its new location. - /// - public func move(_ destination: Path) throws -> () { - try Path.fileManager.moveItem(atPath: self.path, toPath: destination.path) - } - - /// Copy the file or directory to a new location synchronously. - /// - /// - Parameter destination: The new path. This path must include the name of the file or - /// directory in its new location. - /// - public func copy(_ destination: Path) throws -> () { - try Path.fileManager.copyItem(atPath: self.path, toPath: destination.path) - } - - /// Creates a hard link at a new destination. - /// - /// - Parameter destination: The location where the link will be created. - /// - public func link(_ destination: Path) throws -> () { - try Path.fileManager.linkItem(atPath: self.path, toPath: destination.path) - } - - /// Creates a symbolic link at a new destination. - /// - /// - Parameter destintation: The location where the link will be created. - /// - public func symlink(_ destination: Path) throws -> () { - try Path.fileManager.createSymbolicLink(atPath: self.path, withDestinationPath: destination.path) - } -} - - -// MARK: Current Directory - -extension Path { - /// The current working directory of the process - /// - /// - Returns: the current working directory of the process - /// - public static var current: Path { - get { - return self.init(Path.fileManager.currentDirectoryPath) - } - set { - _ = Path.fileManager.changeCurrentDirectoryPath(newValue.description) - } - } - - /// Changes the current working directory of the process to the path during the execution of the - /// given block. - /// - /// - Note: The original working directory is restored when the block returns or throws. - /// - Parameter closure: A closure to be executed while the current directory is configured to - /// the path. - /// - public func chdir(closure: () throws -> ()) rethrows { - let previous = Path.current - Path.current = self - defer { Path.current = previous } - try closure() - } -} - - -// MARK: Temporary - -extension Path { - /// - Returns: the path to either the user’s or application’s home directory, - /// depending on the platform. - /// - public static var home: Path { - return Path(NSHomeDirectory()) - } - - /// - Returns: the path of the temporary directory for the current user. - /// - public static var temporary: Path { - return Path(NSTemporaryDirectory()) - } - - /// - Returns: the path of a temporary directory unique for the process. - /// - Note: Based on `NSProcessInfo.globallyUniqueString`. - /// - public static func processUniqueTemporary() throws -> Path { - let path = temporary + ProcessInfo.processInfo.globallyUniqueString - if !path.exists { - try path.mkdir() - } - return path - } - - /// - Returns: the path of a temporary directory unique for each call. - /// - Note: Based on `NSUUID`. - /// - public static func uniqueTemporary() throws -> Path { - let path = try processUniqueTemporary() + UUID().uuidString - try path.mkdir() - return path - } -} - - -// MARK: Contents - -extension Path { - /// Reads the file. - /// - /// - Returns: the contents of the file at the specified path. - /// - public func read() throws -> Data { - return try Data(contentsOf: self.url, options: NSData.ReadingOptions(rawValue: 0)) - } - - /// Reads the file contents and encoded its bytes to string applying the given encoding. - /// - /// - Parameter encoding: the encoding which should be used to decode the data. - /// (by default: `NSUTF8StringEncoding`) - /// - /// - Returns: the contents of the file at the specified path as string. - /// - public func read(_ encoding: String.Encoding = String.Encoding.utf8) throws -> String { - return try NSString(contentsOfFile: path, encoding: encoding.rawValue).substring(from: 0) as String - } - - /// Write a file. - /// - /// - Note: Works atomically: the data is written to a backup file, and then — assuming no - /// errors occur — the backup file is renamed to the name specified by path. - /// - /// - Parameter data: the contents to write to file. - /// - public func write(_ data: Data) throws { - try data.write(to: normalize().url, options: .atomic) - } - - /// Reads the file. - /// - /// - Note: Works atomically: the data is written to a backup file, and then — assuming no - /// errors occur — the backup file is renamed to the name specified by path. - /// - /// - Parameter string: the string to write to file. - /// - /// - Parameter encoding: the encoding which should be used to represent the string as bytes. - /// (by default: `NSUTF8StringEncoding`) - /// - /// - Returns: the contents of the file at the specified path as string. - /// - public func write(_ string: String, encoding: String.Encoding = String.Encoding.utf8) throws { - try string.write(toFile: normalize().path, atomically: true, encoding: encoding) - } -} - - -// MARK: Traversing - -extension Path { - /// Get the parent directory - /// - /// - Returns: the normalized path of the parent directory - /// - public func parent() -> Path { - return self + ".." - } - - /// Performs a shallow enumeration in a directory - /// - /// - Returns: paths to all files, directories and symbolic links contained in the directory - /// - public func children() throws -> [Path] { - return try Path.fileManager.contentsOfDirectory(atPath: path).map { - self + Path($0) - } - } - - /// Performs a deep enumeration in a directory - /// - /// - Returns: paths to all files, directories and symbolic links contained in the directory or - /// any subdirectory. - /// - public func recursiveChildren() throws -> [Path] { - return try Path.fileManager.subpathsOfDirectory(atPath: path).map { - self + Path($0) - } - } -} - - -// MARK: Globbing - -extension Path { - public static func glob(_ pattern: String) -> [Path] { - var gt = glob_t() - guard let cPattern = strdup(pattern) else { - fatalError("strdup returned null: Likely out of memory") - } - defer { - globfree(>) - free(cPattern) - } - - let flags = GLOB_TILDE | GLOB_BRACE | GLOB_MARK - if system_glob(cPattern, flags, nil, >) == 0 { -#if os(Linux) - let matchc = gt.gl_pathc -#else - let matchc = gt.gl_matchc -#endif - return (0.. [Path] { - return Path.glob((self + pattern).description) - } - - public func match(_ pattern: String) -> Bool { - guard let cPattern = strdup(pattern), - let cPath = strdup(path) else { - fatalError("strdup returned null: Likely out of memory") - } - defer { - free(cPattern) - free(cPath) - } - return fnmatch(cPattern, cPath, 0) == 0 - } -} - - -// MARK: SequenceType - -extension Path : Sequence { - public struct DirectoryEnumerationOptions : OptionSet { - public let rawValue: UInt - public init(rawValue: UInt) { - self.rawValue = rawValue - } - - public static var skipsSubdirectoryDescendants = DirectoryEnumerationOptions(rawValue: FileManager.DirectoryEnumerationOptions.skipsSubdirectoryDescendants.rawValue) - public static var skipsPackageDescendants = DirectoryEnumerationOptions(rawValue: FileManager.DirectoryEnumerationOptions.skipsPackageDescendants.rawValue) - public static var skipsHiddenFiles = DirectoryEnumerationOptions(rawValue: FileManager.DirectoryEnumerationOptions.skipsHiddenFiles.rawValue) - } - - /// Represents a path sequence with specific enumeration options - public struct PathSequence : Sequence { - private var path: Path - private var options: DirectoryEnumerationOptions - init(path: Path, options: DirectoryEnumerationOptions) { - self.path = path - self.options = options - } - - public func makeIterator() -> DirectoryEnumerator { - return DirectoryEnumerator(path: path, options: options) - } - } - - /// Enumerates the contents of a directory, returning the paths of all files and directories - /// contained within that directory. These paths are relative to the directory. - public struct DirectoryEnumerator : IteratorProtocol { - public typealias Element = Path - - let path: Path - let directoryEnumerator: FileManager.DirectoryEnumerator? - - init(path: Path, options mask: DirectoryEnumerationOptions = []) { - let options = FileManager.DirectoryEnumerationOptions(rawValue: mask.rawValue) - self.path = path - self.directoryEnumerator = Path.fileManager.enumerator(at: path.url, includingPropertiesForKeys: nil, options: options) - } - - public func next() -> Path? { - let next = directoryEnumerator?.nextObject() - - if let next = next as? URL { - return Path(next.path) - } - return nil - } - - /// Skip recursion into the most recently obtained subdirectory. - public func skipDescendants() { - directoryEnumerator?.skipDescendants() - } - } - - /// Perform a deep enumeration of a directory. - /// - /// - Returns: a directory enumerator that can be used to perform a deep enumeration of the - /// directory. - /// - public func makeIterator() -> DirectoryEnumerator { - return DirectoryEnumerator(path: self) - } - - /// Perform a deep enumeration of a directory. - /// - /// - Parameter options: FileManager directory enumerator options. - /// - /// - Returns: a path sequence that can be used to perform a deep enumeration of the - /// directory. - /// - public func iterateChildren(options: DirectoryEnumerationOptions = []) -> PathSequence { - return PathSequence(path: self, options: options) - } -} - - -// MARK: Equatable - -extension Path : Equatable {} - -/// Determines if two paths are identical -/// -/// - Note: The comparison is string-based. Be aware that two different paths (foo.txt and -/// ./foo.txt) can refer to the same file. -/// -public func ==(lhs: Path, rhs: Path) -> Bool { - return lhs.path == rhs.path -} - - -// MARK: Pattern Matching - -/// Implements pattern-matching for paths. -/// -/// - Returns: `true` iff one of the following conditions is true: -/// - the paths are equal (based on `Path`'s `Equatable` implementation) -/// - the paths can be normalized to equal Paths. -/// -public func ~=(lhs: Path, rhs: Path) -> Bool { - return lhs == rhs - || lhs.normalize() == rhs.normalize() -} - - -// MARK: Comparable - -extension Path : Comparable {} - -/// Defines a strict total order over Paths based on their underlying string representation. -public func <(lhs: Path, rhs: Path) -> Bool { - return lhs.path < rhs.path -} - - -// MARK: Operators - -/// Appends a Path fragment to another Path to produce a new Path -public func +(lhs: Path, rhs: Path) -> Path { - return lhs.path + rhs.path -} - -/// Appends a String fragment to another Path to produce a new Path -public func +(lhs: Path, rhs: String) -> Path { - return lhs.path + rhs -} - -/// Appends a String fragment to another String to produce a new Path -internal func +(lhs: String, rhs: String) -> Path { - if rhs.hasPrefix(Path.separator) { - // Absolute paths replace relative paths - return Path(rhs) - } else { - var lSlice = NSString(string: lhs).pathComponents.fullSlice - var rSlice = NSString(string: rhs).pathComponents.fullSlice - - // Get rid of trailing "/" at the left side - if lSlice.count > 1 && lSlice.last == Path.separator { - lSlice.removeLast() - } - - // Advance after the first relevant "." - lSlice = lSlice.filter { $0 != "." }.fullSlice - rSlice = rSlice.filter { $0 != "." }.fullSlice - - // Eats up trailing components of the left and leading ".." of the right side - while lSlice.last != ".." && !lSlice.isEmpty && rSlice.first == ".." { - if lSlice.count > 1 || lSlice.first != Path.separator { - // A leading "/" is never popped - lSlice.removeLast() - } - if !rSlice.isEmpty { - rSlice.removeFirst() - } - - switch (lSlice.isEmpty, rSlice.isEmpty) { - case (true, _): - break - case (_, true): - break - default: - continue - } - } - - return Path(components: lSlice + rSlice) - } -} - -extension Array { - var fullSlice: ArraySlice { - return self[self.indices.suffix(from: 0)] - } -} diff --git a/Sources/PathKit/Internal/Array+FullSlice.swift b/Sources/PathKit/Internal/Array+FullSlice.swift new file mode 100644 index 0000000..98fa962 --- /dev/null +++ b/Sources/PathKit/Internal/Array+FullSlice.swift @@ -0,0 +1,18 @@ +// Array+FullSlice.swift +// PathKit +// +// Copyright (c) 2014, Kyle Fuller +// All rights reserved. +// Version 1.0.1 +// +// Copyright © 2024 MFB Technologies, Inc. All rights reserved. +// After Version 1.0.1 +// +// This source code is licensed under the BSD-2-Clause License found in the +// LICENSE file in the root directory of this source tree. + +extension Array { + var fullSlice: ArraySlice { + self[indices.suffix(from: 0)] + } +} diff --git a/Sources/PathKit/Internal/DefaultFileSystemInfo.swift b/Sources/PathKit/Internal/DefaultFileSystemInfo.swift new file mode 100644 index 0000000..7552ddd --- /dev/null +++ b/Sources/PathKit/Internal/DefaultFileSystemInfo.swift @@ -0,0 +1,33 @@ +// DefaultFileSystemInfo.swift +// PathKit +// +// Copyright (c) 2014, Kyle Fuller +// All rights reserved. +// Version 1.0.1 +// +// Copyright © 2024 MFB Technologies, Inc. All rights reserved. +// After Version 1.0.1 +// +// This source code is licensed under the BSD-2-Clause License found in the +// LICENSE file in the root directory of this source tree. + +struct DefaultFileSystemInfo: FileSystemInfo { + func isFSCaseSensitiveAt(path: Path) -> Bool { + #if os(Linux) + // URL resourceValues(forKeys:) is not supported on non-darwin platforms... + // But we can (fairly?) safely assume for now that the Linux FS is case sensitive. + // TODO: refactor when/if resourceValues is available, or look into using something + // like stat or pathconf to determine if the mountpoint is case sensitive. + return true + #else + var isCaseSensitive = false + // Calling resourceValues will fail if the path does not exist on the filesystem, which + // makes sense, but means we can only guarantee the return value is correct if the + // path actually exists. + if let resourceValues = try? path.url.resourceValues(forKeys: [.volumeSupportsCaseSensitiveNamesKey]) { + isCaseSensitive = resourceValues.volumeSupportsCaseSensitiveNames ?? isCaseSensitive + } + return isCaseSensitive + #endif + } +} diff --git a/Sources/PathKit/Internal/FileSystemInfo.swift b/Sources/PathKit/Internal/FileSystemInfo.swift new file mode 100644 index 0000000..f8d11cb --- /dev/null +++ b/Sources/PathKit/Internal/FileSystemInfo.swift @@ -0,0 +1,16 @@ +// FileSystemInfo.swift +// PathKit +// +// Copyright (c) 2014, Kyle Fuller +// All rights reserved. +// Version 1.0.1 +// +// Copyright © 2024 MFB Technologies, Inc. All rights reserved. +// After Version 1.0.1 +// +// This source code is licensed under the BSD-2-Clause License found in the +// LICENSE file in the root directory of this source tree. + +protocol FileSystemInfo: Sendable { + func isFSCaseSensitiveAt(path: Path) -> Bool +} diff --git a/Sources/PathKit/Operators.swift b/Sources/PathKit/Operators.swift new file mode 100644 index 0000000..efd62e9 --- /dev/null +++ b/Sources/PathKit/Operators.swift @@ -0,0 +1,68 @@ +// Operators.swift +// PathKit +// +// Copyright (c) 2014, Kyle Fuller +// All rights reserved. +// Version 1.0.1 +// +// Copyright © 2024 MFB Technologies, Inc. All rights reserved. +// After Version 1.0.1 +// +// This source code is licensed under the BSD-2-Clause License found in the +// LICENSE file in the root directory of this source tree. + +import Foundation + +extension Path { + /// Appends a Path fragment to another Path to produce a new Path + public static func + (lhs: Path, rhs: Path) -> Path { + lhs.path + rhs.path + } + + /// Appends a String fragment to another Path to produce a new Path + public static func + (lhs: Path, rhs: String) -> Path { + lhs.path + rhs + } +} + +/// Appends a String fragment to another String to produce a new Path +func + (lhs: String, rhs: String) -> Path { + if rhs.hasPrefix(Path.separator) { + // Absolute paths replace relative paths + return Path(rhs) + } else { + var lSlice = NSString(string: lhs).pathComponents.fullSlice + var rSlice = NSString(string: rhs).pathComponents.fullSlice + + // Get rid of trailing "/" at the left side + if lSlice.count > 1, lSlice.last == Path.separator { + lSlice.removeLast() + } + + // Advance after the first relevant "." + lSlice = lSlice.filter { $0 != "." }.fullSlice + rSlice = rSlice.filter { $0 != "." }.fullSlice + + // Eats up trailing components of the left and leading ".." of the right side + while lSlice.last != "..", !lSlice.isEmpty, rSlice.first == ".." { + if lSlice.count > 1 || lSlice.first != Path.separator { + // A leading "/" is never popped + lSlice.removeLast() + } + if !rSlice.isEmpty { + rSlice.removeFirst() + } + + switch (lSlice.isEmpty, rSlice.isEmpty) { + case (true, _): + break + case (_, true): + break + default: + continue + } + } + + return Path(components: lSlice + rSlice) + } +} diff --git a/Sources/PathKit/Path+Components.swift b/Sources/PathKit/Path+Components.swift new file mode 100644 index 0000000..5cbdc87 --- /dev/null +++ b/Sources/PathKit/Path+Components.swift @@ -0,0 +1,56 @@ +// Path+Components.swift +// PathKit +// +// Copyright (c) 2014, Kyle Fuller +// All rights reserved. +// Version 1.0.1 +// +// Copyright © 2024 MFB Technologies, Inc. All rights reserved. +// After Version 1.0.1 +// +// This source code is licensed under the BSD-2-Clause License found in the +// LICENSE file in the root directory of this source tree. + +import Foundation + +extension Path { + /// The last path component + /// + /// - Returns: the last path component + /// + public var lastComponent: String { + NSString(string: path).lastPathComponent + } + + /// The last path component without file extension + /// + /// - Note: This returns "." for ".." on Linux, and ".." on Apple platforms. + /// + /// - Returns: the last path component without file extension + /// + public var lastComponentWithoutExtension: String { + NSString(string: lastComponent).deletingPathExtension + } + + /// Splits the string representation on the directory separator. + /// Absolute paths remain the leading slash as first component. + /// + /// - Returns: all path components + /// + public var components: [String] { + NSString(string: path).pathComponents + } + + /// The file extension behind the last dot of the last component. + /// + /// - Returns: the file extension + /// + public var `extension`: String? { + let pathExtension = NSString(string: path).pathExtension + if pathExtension.isEmpty { + return nil + } + + return pathExtension + } +} diff --git a/Sources/PathKit/Path+Contents.swift b/Sources/PathKit/Path+Contents.swift new file mode 100644 index 0000000..5146abc --- /dev/null +++ b/Sources/PathKit/Path+Contents.swift @@ -0,0 +1,62 @@ +// Path+Contents.swift +// PathKit +// +// Copyright (c) 2014, Kyle Fuller +// All rights reserved. +// Version 1.0.1 +// +// Copyright © 2024 MFB Technologies, Inc. All rights reserved. +// After Version 1.0.1 +// +// This source code is licensed under the BSD-2-Clause License found in the +// LICENSE file in the root directory of this source tree. + +import Foundation + +extension Path { + /// Reads the file. + /// + /// - Returns: the contents of the file at the specified path. + /// + public func read() throws -> Data { + try Data(contentsOf: url, options: NSData.ReadingOptions(rawValue: 0)) + } + + /// Reads the file contents and encoded its bytes to string applying the given encoding. + /// + /// - Parameter encoding: the encoding which should be used to decode the data. + /// (by default: `NSUTF8StringEncoding`) + /// + /// - Returns: the contents of the file at the specified path as string. + /// + public func read(_ encoding: String.Encoding = String.Encoding.utf8) throws -> String { + try NSString(contentsOfFile: path, encoding: encoding.rawValue).substring(from: 0) as String + } + + /// Write a file. + /// + /// - Note: Works atomically: the data is written to a backup file, and then — assuming no + /// errors occur — the backup file is renamed to the name specified by path. + /// + /// - Parameter data: the contents to write to file. + /// + public func write(_ data: Data) throws { + try data.write(to: normalize().url, options: .atomic) + } + + /// Reads the file. + /// + /// - Note: Works atomically: the data is written to a backup file, and then — assuming no + /// errors occur — the backup file is renamed to the name specified by path. + /// + /// - Parameter string: the string to write to file. + /// + /// - Parameter encoding: the encoding which should be used to represent the string as bytes. + /// (by default: `NSUTF8StringEncoding`) + /// + /// - Returns: the contents of the file at the specified path as string. + /// + public func write(_ string: String, encoding: String.Encoding = String.Encoding.utf8) throws { + try string.write(toFile: normalize().path, atomically: true, encoding: encoding) + } +} diff --git a/Sources/PathKit/Path+Directories.swift b/Sources/PathKit/Path+Directories.swift new file mode 100644 index 0000000..bc4b5a2 --- /dev/null +++ b/Sources/PathKit/Path+Directories.swift @@ -0,0 +1,76 @@ +// Path+Directories.swift +// PathKit +// +// Copyright (c) 2014, Kyle Fuller +// All rights reserved. +// Version 1.0.1 +// +// Copyright © 2024 MFB Technologies, Inc. All rights reserved. +// After Version 1.0.1 +// +// This source code is licensed under the BSD-2-Clause License found in the +// LICENSE file in the root directory of this source tree. + +import Foundation + +extension Path { + /// The current working directory of the process + /// + /// - Returns: the current working directory of the process + /// + public static var current: Path { + get { + self.init(Path.fileManager.currentDirectoryPath) + } + set { + _ = Path.fileManager.changeCurrentDirectoryPath(newValue.description) + } + } + + /// Changes the current working directory of the process to the path during the execution of the + /// given block. + /// + /// - Note: The original working directory is restored when the block returns or throws. + /// - Parameter closure: A closure to be executed while the current directory is configured to + /// the path. + /// + public func chdir(closure: () throws -> Void) rethrows { + let previous = Path.current + Path.current = self + defer { Path.current = previous } + try closure() + } + + /// - Returns: the path to either the user’s or application’s home directory, + /// depending on the platform. + /// + public static var home: Path { + Path(NSHomeDirectory()) + } + + /// - Returns: the path of the temporary directory for the current user. + /// + public static var temporary: Path { + Path(NSTemporaryDirectory()) + } + + /// - Returns: the path of a temporary directory unique for the process. + /// - Note: Based on `NSProcessInfo.globallyUniqueString`. + /// + public static func processUniqueTemporary() throws -> Path { + let path = temporary + ProcessInfo.processInfo.globallyUniqueString + if !path.exists { + try path.mkdir() + } + return path + } + + /// - Returns: the path of a temporary directory unique for each call. + /// - Note: Based on `NSUUID`. + /// + public static func uniqueTemporary() throws -> Path { + let path = try processUniqueTemporary() + UUID().uuidString + try path.mkdir() + return path + } +} diff --git a/Sources/PathKit/Path+FileInfo.swift b/Sources/PathKit/Path+FileInfo.swift new file mode 100644 index 0000000..d2fa8c2 --- /dev/null +++ b/Sources/PathKit/Path+FileInfo.swift @@ -0,0 +1,108 @@ +// Path+FileInfo.swift +// PathKit +// +// Copyright (c) 2014, Kyle Fuller +// All rights reserved. +// Version 1.0.1 +// +// Copyright © 2024 MFB Technologies, Inc. All rights reserved. +// After Version 1.0.1 +// +// This source code is licensed under the BSD-2-Clause License found in the +// LICENSE file in the root directory of this source tree. + +import Foundation + +extension Path { + /// Test whether a file or directory exists at a specified path + /// + /// - Returns: `false` iff the path doesn't exist on disk or its existence could not be + /// determined + /// + public var exists: Bool { + Path.fileManager.fileExists(atPath: path) + } + + /// Test whether a path is a directory. + /// + /// - Returns: `true` if the path is a directory or a symbolic link that points to a directory; + /// `false` if the path is not a directory or the path doesn't exist on disk or its existence + /// could not be determined + /// + public var isDirectory: Bool { + var directory = ObjCBool(false) + guard Path.fileManager.fileExists(atPath: normalize().path, isDirectory: &directory) else { + return false + } + return directory.boolValue + } + + /// Test whether a path is a regular file. + /// + /// - Returns: `true` if the path is neither a directory nor a symbolic link that points to a + /// directory; `false` if the path is a directory or a symbolic link that points to a + /// directory or the path doesn't exist on disk or its existence + /// could not be determined + /// + public var isFile: Bool { + var directory = ObjCBool(false) + guard Path.fileManager.fileExists(atPath: normalize().path, isDirectory: &directory) else { + return false + } + return !directory.boolValue + } + + /// Test whether a path is a symbolic link. + /// + /// - Returns: `true` if the path is a symbolic link; `false` if the path doesn't exist on disk + /// or its existence could not be determined + /// + public var isSymlink: Bool { + do { + try Path.fileManager.destinationOfSymbolicLink(atPath: path) + return true + } catch { + return false + } + } + + /// Test whether a path is readable + /// + /// - Returns: `true` if the current process has read privileges for the file at path; + /// otherwise `false` if the process does not have read privileges or the existence of the + /// file could not be determined. + /// + public var isReadable: Bool { + Path.fileManager.isReadableFile(atPath: path) + } + + /// Test whether a path is writeable + /// + /// - Returns: `true` if the current process has write privileges for the file at path; + /// otherwise `false` if the process does not have write privileges or the existence of the + /// file could not be determined. + /// + public var isWritable: Bool { + Path.fileManager.isWritableFile(atPath: path) + } + + /// Test whether a path is executable + /// + /// - Returns: `true` if the current process has execute privileges for the file at path; + /// otherwise `false` if the process does not have execute privileges or the existence of the + /// file could not be determined. + /// + public var isExecutable: Bool { + Path.fileManager.isExecutableFile(atPath: path) + } + + /// Test whether a path is deletable + /// + /// - Returns: `true` if the current process has delete privileges for the file at path; + /// otherwise `false` if the process does not have delete privileges or the existence of the + /// file could not be determined. + /// + public var isDeletable: Bool { + Path.fileManager.isDeletableFile(atPath: path) + } +} diff --git a/Sources/PathKit/Path+FileManipulation.swift b/Sources/PathKit/Path+FileManipulation.swift new file mode 100644 index 0000000..071e622 --- /dev/null +++ b/Sources/PathKit/Path+FileManipulation.swift @@ -0,0 +1,78 @@ +// Path+FileManipulation.swift +// PathKit +// +// Copyright (c) 2014, Kyle Fuller +// All rights reserved. +// Version 1.0.1 +// +// Copyright © 2024 MFB Technologies, Inc. All rights reserved. +// After Version 1.0.1 +// +// This source code is licensed under the BSD-2-Clause License found in the +// LICENSE file in the root directory of this source tree. + +import Foundation + +extension Path { + /// Create the directory. + /// + /// - Note: This method fails if any of the intermediate parent directories does not exist. + /// This method also fails if any of the intermediate path elements corresponds to a file and + /// not a directory. + /// + public func mkdir() throws { + try Path.fileManager.createDirectory(atPath: path, withIntermediateDirectories: false, attributes: nil) + } + + /// Create the directory and any intermediate parent directories that do not exist. + /// + /// - Note: This method fails if any of the intermediate path elements corresponds to a file and + /// not a directory. + /// + public func mkpath() throws { + try Path.fileManager.createDirectory(atPath: path, withIntermediateDirectories: true, attributes: nil) + } + + /// Delete the file or directory. + /// + /// - Note: If the path specifies a directory, the contents of that directory are recursively + /// removed. + /// + public func delete() throws { + try Path.fileManager.removeItem(atPath: path) + } + + /// Move the file or directory to a new location synchronously. + /// + /// - Parameter destination: The new path. This path must include the name of the file or + /// directory in its new location. + /// + public func move(_ destination: Path) throws { + try Path.fileManager.moveItem(atPath: path, toPath: destination.path) + } + + /// Copy the file or directory to a new location synchronously. + /// + /// - Parameter destination: The new path. This path must include the name of the file or + /// directory in its new location. + /// + public func copy(_ destination: Path) throws { + try Path.fileManager.copyItem(atPath: path, toPath: destination.path) + } + + /// Creates a hard link at a new destination. + /// + /// - Parameter destination: The location where the link will be created. + /// + public func link(_ destination: Path) throws { + try Path.fileManager.linkItem(atPath: path, toPath: destination.path) + } + + /// Creates a symbolic link at a new destination. + /// + /// - Parameter destintation: The location where the link will be created. + /// + public func symlink(_ destination: Path) throws { + try Path.fileManager.createSymbolicLink(atPath: path, withDestinationPath: destination.path) + } +} diff --git a/Sources/PathKit/Path+Globbing.swift b/Sources/PathKit/Path+Globbing.swift new file mode 100644 index 0000000..2996563 --- /dev/null +++ b/Sources/PathKit/Path+Globbing.swift @@ -0,0 +1,73 @@ +// Path+Globbing.swift +// PathKit +// +// Copyright (c) 2014, Kyle Fuller +// All rights reserved. +// Version 1.0.1 +// +// Copyright © 2024 MFB Technologies, Inc. All rights reserved. +// After Version 1.0.1 +// +// This source code is licensed under the BSD-2-Clause License found in the +// LICENSE file in the root directory of this source tree. + +#if os(Linux) + import Glibc + + let system_glob = Glibc.glob +#else + import Darwin + + let system_glob = Darwin.glob +#endif + +import Foundation + +extension Path { + public static func glob(_ pattern: String) -> [Path] { + var _glob_t = glob_t() + guard let cPattern = strdup(pattern) else { + fatalError("strdup returned null: Likely out of memory") + } + defer { + globfree(&_glob_t) + free(cPattern) + } + + let flags = GLOB_TILDE | GLOB_BRACE | GLOB_MARK + if system_glob(cPattern, flags, nil, &_glob_t) == 0 { + #if os(Linux) + let matchc = _glob_t.gl_pathc + #else + let matchc = _glob_t.gl_matchc + #endif + return (0 ..< Int(matchc)).compactMap { index in + if let path = String(validatingUTF8: _glob_t.gl_pathv[index]!) { + return Path(path) + } + + return nil + } + } + + // GLOB_NOMATCH + return [] + } + + public func glob(_ pattern: String) -> [Path] { + Path.glob((self + pattern).description) + } + + public func match(_ pattern: String) -> Bool { + guard let cPattern = strdup(pattern), + let cPath = strdup(path) + else { + fatalError("strdup returned null: Likely out of memory") + } + defer { + free(cPattern) + free(cPath) + } + return fnmatch(cPattern, cPath, 0) == 0 + } +} diff --git a/Sources/PathKit/Path+PathInfo.swift b/Sources/PathKit/Path+PathInfo.swift new file mode 100644 index 0000000..9cc1b8e --- /dev/null +++ b/Sources/PathKit/Path+PathInfo.swift @@ -0,0 +1,94 @@ +// Path+PathInfo.swift +// PathKit +// +// Copyright (c) 2014, Kyle Fuller +// All rights reserved. +// Version 1.0.1 +// +// Copyright © 2024 MFB Technologies, Inc. All rights reserved. +// After Version 1.0.1 +// +// This source code is licensed under the BSD-2-Clause License found in the +// LICENSE file in the root directory of this source tree. + +import Foundation + +extension Path { + /// Test whether a path is absolute. + /// + /// - Returns: `true` iff the path begins with a slash + /// + public var isAbsolute: Bool { + path.hasPrefix(Path.separator) + } + + /// Test whether a path is relative. + /// + /// - Returns: `true` iff a path is relative (not absolute) + /// + public var isRelative: Bool { + !isAbsolute + } + + /// Concatenates relative paths to the current directory and derives the normalized path + /// + /// - Returns: the absolute path in the actual filesystem + /// + public func absolute() -> Path { + if isAbsolute { + return normalize() + } + + let expandedPath = Path(NSString(string: path).expandingTildeInPath) + if expandedPath.isAbsolute { + return expandedPath.normalize() + } + + return (Path.current + self).normalize() + } + + /// Normalizes the path, this cleans up redundant ".." and ".", double slashes + /// and resolves "~". + /// + /// - Returns: a new path made by removing extraneous path components from the underlying String + /// representation. + /// + public func normalize() -> Path { + Path(NSString(string: path).standardizingPath) + } + + /// De-normalizes the path, by replacing the current user home directory with "~". + /// + /// - Returns: a new path made by removing extraneous path components from the underlying String + /// representation. + /// + public func abbreviate() -> Path { + let rangeOptions: String.CompareOptions = fileSystemInfo.isFSCaseSensitiveAt(path: self) ? + [.anchored] : [.anchored, .caseInsensitive] + let home = Path.home.string + guard let homeRange = path.range(of: home, options: rangeOptions) else { return self } + let withoutHome = Path(path.replacingCharacters(in: homeRange, with: "")) + + if withoutHome.path.isEmpty || withoutHome.path == Path.separator { + return Path("~") + } else if withoutHome.isAbsolute { + return Path("~" + withoutHome.path) + } else { + return Path("~") + withoutHome.path + } + } + + /// Returns the path of the item pointed to by a symbolic link. + /// + /// - Returns: the path of directory or file to which the symbolic link refers + /// + public func symlinkDestination() throws -> Path { + let symlinkDestination = try Path.fileManager.destinationOfSymbolicLink(atPath: path) + let symlinkPath = Path(symlinkDestination) + if symlinkPath.isRelative { + return self + ".." + symlinkPath + } else { + return symlinkPath + } + } +} diff --git a/Sources/PathKit/Path+Sequence.swift b/Sources/PathKit/Path+Sequence.swift new file mode 100644 index 0000000..3193239 --- /dev/null +++ b/Sources/PathKit/Path+Sequence.swift @@ -0,0 +1,101 @@ +// Path+Sequence.swift +// PathKit +// +// Copyright (c) 2014, Kyle Fuller +// All rights reserved. +// Version 1.0.1 +// +// Copyright © 2024 MFB Technologies, Inc. All rights reserved. +// After Version 1.0.1 +// +// This source code is licensed under the BSD-2-Clause License found in the +// LICENSE file in the root directory of this source tree. + +import Foundation + +extension Path: Sequence { + public struct DirectoryEnumerationOptions: OptionSet { + public let rawValue: UInt + public init(rawValue: UInt) { + self.rawValue = rawValue + } + + public static var skipsSubdirectoryDescendants = DirectoryEnumerationOptions( + rawValue: FileManager + .DirectoryEnumerationOptions.skipsSubdirectoryDescendants.rawValue + ) + public static var skipsPackageDescendants = DirectoryEnumerationOptions( + rawValue: FileManager + .DirectoryEnumerationOptions.skipsPackageDescendants.rawValue + ) + public static var skipsHiddenFiles = DirectoryEnumerationOptions( + rawValue: FileManager.DirectoryEnumerationOptions + .skipsHiddenFiles.rawValue + ) + } + + /// Represents a path sequence with specific enumeration options + public struct PathSequence: Sequence { + private var path: Path + private var options: DirectoryEnumerationOptions + init(path: Path, options: DirectoryEnumerationOptions) { + self.path = path + self.options = options + } + + public func makeIterator() -> DirectoryEnumerator { + DirectoryEnumerator(path: path, options: options) + } + } + + /// Enumerates the contents of a directory, returning the paths of all files and directories + /// contained within that directory. These paths are relative to the directory. + public struct DirectoryEnumerator: IteratorProtocol { + let path: Path + let directoryEnumerator: FileManager.DirectoryEnumerator? + + init(path: Path, options mask: DirectoryEnumerationOptions = []) { + let options = FileManager.DirectoryEnumerationOptions(rawValue: mask.rawValue) + self.path = path + directoryEnumerator = Path.fileManager.enumerator( + at: path.url, + includingPropertiesForKeys: nil, + options: options + ) + } + + public func next() -> Path? { + let next = directoryEnumerator?.nextObject() + + if let next = next as? URL { + return Path(next.path) + } + return nil + } + + /// Skip recursion into the most recently obtained subdirectory. + public func skipDescendants() { + directoryEnumerator?.skipDescendants() + } + } + + /// Perform a deep enumeration of a directory. + /// + /// - Returns: a directory enumerator that can be used to perform a deep enumeration of the + /// directory. + /// + public func makeIterator() -> DirectoryEnumerator { + DirectoryEnumerator(path: self) + } + + /// Perform a deep enumeration of a directory. + /// + /// - Parameter options: FileManager directory enumerator options. + /// + /// - Returns: a path sequence that can be used to perform a deep enumeration of the + /// directory. + /// + public func iterateChildren(options: DirectoryEnumerationOptions = []) -> PathSequence { + PathSequence(path: self, options: options) + } +} diff --git a/Sources/PathKit/Path+Traversing.swift b/Sources/PathKit/Path+Traversing.swift new file mode 100644 index 0000000..55e09bc --- /dev/null +++ b/Sources/PathKit/Path+Traversing.swift @@ -0,0 +1,45 @@ +// Path+Traversing.swift +// PathKit +// +// Copyright (c) 2014, Kyle Fuller +// All rights reserved. +// Version 1.0.1 +// +// Copyright © 2024 MFB Technologies, Inc. All rights reserved. +// After Version 1.0.1 +// +// This source code is licensed under the BSD-2-Clause License found in the +// LICENSE file in the root directory of this source tree. + +import Foundation + +extension Path { + /// Get the parent directory + /// + /// - Returns: the normalized path of the parent directory + /// + public func parent() -> Path { + self + ".." + } + + /// Performs a shallow enumeration in a directory + /// + /// - Returns: paths to all files, directories and symbolic links contained in the directory + /// + public func children() throws -> [Path] { + try Path.fileManager.contentsOfDirectory(atPath: path).map { + self + Path($0) + } + } + + /// Performs a deep enumeration in a directory + /// + /// - Returns: paths to all files, directories and symbolic links contained in the directory or + /// any subdirectory. + /// + public func recursiveChildren() throws -> [Path] { + try Path.fileManager.subpathsOfDirectory(atPath: path).map { + self + Path($0) + } + } +} diff --git a/Sources/PathKit/Path.swift b/Sources/PathKit/Path.swift new file mode 100644 index 0000000..e66baee --- /dev/null +++ b/Sources/PathKit/Path.swift @@ -0,0 +1,135 @@ +// Path.swift +// PathKit +// +// Copyright (c) 2014, Kyle Fuller +// All rights reserved. +// Version 1.0.1 +// +// Copyright © 2024 MFB Technologies, Inc. All rights reserved. +// After Version 1.0.1 +// +// This source code is licensed under the BSD-2-Clause License found in the +// LICENSE file in the root directory of this source tree. + +import Foundation + +/// Represents a filesystem path. +public struct Path: Sendable { + /// The character used by the OS to separate two path elements + public static let separator = "/" + + /// The underlying string representation + let path: String + + static let fileManager = FileManager.default + + let fileSystemInfo: any FileSystemInfo + + // MARK: Init + + public init() { + self.init("") + } + + /// Create a Path from a given String + public init(_ path: String) { + self.init(path, fileSystemInfo: DefaultFileSystemInfo()) + } + + init(_ path: String, fileSystemInfo: any FileSystemInfo) { + self.path = path + self.fileSystemInfo = fileSystemInfo + } + + init(fileSystemInfo: any FileSystemInfo) { + self.init("", fileSystemInfo: fileSystemInfo) + } + + /// Create a Path by joining multiple path components together + public init(components: S) where S.Iterator.Element == String { + let path: String + if components.isEmpty { + path = "." + } else if components.first == Path.separator, components.count > 1 { + let _path = components.joined(separator: Path.separator) + path = String(_path[_path.index(after: _path.startIndex)...]) + } else { + path = components.joined(separator: Path.separator) + } + self.init(path) + } +} + +extension Path: ExpressibleByStringLiteral { + public typealias ExtendedGraphemeClusterLiteralType = StringLiteralType + public typealias UnicodeScalarLiteralType = StringLiteralType + + public init(extendedGraphemeClusterLiteral path: StringLiteralType) { + self.init(stringLiteral: path) + } + + public init(unicodeScalarLiteral path: StringLiteralType) { + self.init(stringLiteral: path) + } + + public init(stringLiteral value: StringLiteralType) { + self.init(value) + } +} + +extension Path: CustomStringConvertible { + public var description: String { + path + } +} + +extension Path: Equatable { + /// Determines if two paths are identical + /// + /// - Note: The comparison is string-based. Be aware that two different paths (foo.txt and + /// ./foo.txt) can refer to the same file. + /// + public static func == (lhs: Path, rhs: Path) -> Bool { + lhs.path == rhs.path + } +} + +extension Path: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(path.hashValue) + } +} + +extension Path: Comparable { + /// Defines a strict total order over Paths based on their underlying string representation. + public static func < (lhs: Path, rhs: Path) -> Bool { + lhs.path < rhs.path + } +} + +// MARK: Conversion + +extension Path { + public var string: String { + path + } + + public var url: URL { + URL(fileURLWithPath: path) + } +} + +// MARK: Pattern Matching + +extension Path { + /// Implements pattern-matching for paths. + /// + /// - Returns: `true` iff one of the following conditions is true: + /// - the paths are equal (based on `Path`'s `Equatable` implementation) + /// - the paths can be normalized to equal Paths. + /// + public static func ~= (lhs: Path, rhs: Path) -> Bool { + lhs == rhs + || lhs.normalize() == rhs.normalize() + } +} diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift deleted file mode 100644 index cfcd320..0000000 --- a/Tests/LinuxMain.swift +++ /dev/null @@ -1,3 +0,0 @@ -import PathKitTests - -testPathKit() diff --git a/Tests/PathKitTests/Fixtures/directory/.hiddenFile b/Tests/PathKitTests/Fixtures/directory/.hiddenFile deleted file mode 100644 index e69de29..0000000 diff --git a/Tests/PathKitTests/Fixtures/directory/child b/Tests/PathKitTests/Fixtures/directory/child deleted file mode 100644 index e69de29..0000000 diff --git a/Tests/PathKitTests/Fixtures/directory/subdirectory/child b/Tests/PathKitTests/Fixtures/directory/subdirectory/child deleted file mode 100644 index e69de29..0000000 diff --git a/Tests/PathKitTests/Fixtures/file b/Tests/PathKitTests/Fixtures/file deleted file mode 100644 index e69de29..0000000 diff --git a/Tests/PathKitTests/Fixtures/hello b/Tests/PathKitTests/Fixtures/hello deleted file mode 100644 index 557db03..0000000 --- a/Tests/PathKitTests/Fixtures/hello +++ /dev/null @@ -1 +0,0 @@ -Hello World diff --git a/Tests/PathKitTests/Fixtures/permissions/deletable b/Tests/PathKitTests/Fixtures/permissions/deletable deleted file mode 100644 index e69de29..0000000 diff --git a/Tests/PathKitTests/Fixtures/permissions/executable b/Tests/PathKitTests/Fixtures/permissions/executable deleted file mode 100755 index e69de29..0000000 diff --git a/Tests/PathKitTests/Fixtures/permissions/readable b/Tests/PathKitTests/Fixtures/permissions/readable deleted file mode 100644 index e69de29..0000000 diff --git a/Tests/PathKitTests/Fixtures/permissions/writable b/Tests/PathKitTests/Fixtures/permissions/writable deleted file mode 100644 index e69de29..0000000 diff --git a/Tests/PathKitTests/Fixtures/symlinks/directory b/Tests/PathKitTests/Fixtures/symlinks/directory deleted file mode 120000 index 02d1aee..0000000 --- a/Tests/PathKitTests/Fixtures/symlinks/directory +++ /dev/null @@ -1 +0,0 @@ -../directory \ No newline at end of file diff --git a/Tests/PathKitTests/Fixtures/symlinks/file b/Tests/PathKitTests/Fixtures/symlinks/file deleted file mode 120000 index 74bb61d..0000000 --- a/Tests/PathKitTests/Fixtures/symlinks/file +++ /dev/null @@ -1 +0,0 @@ -../file \ No newline at end of file diff --git a/Tests/PathKitTests/Fixtures/symlinks/same-dir b/Tests/PathKitTests/Fixtures/symlinks/same-dir deleted file mode 120000 index 1a010b1..0000000 --- a/Tests/PathKitTests/Fixtures/symlinks/same-dir +++ /dev/null @@ -1 +0,0 @@ -file \ No newline at end of file diff --git a/Tests/PathKitTests/Fixtures/symlinks/swift b/Tests/PathKitTests/Fixtures/symlinks/swift deleted file mode 120000 index f0b7467..0000000 --- a/Tests/PathKitTests/Fixtures/symlinks/swift +++ /dev/null @@ -1 +0,0 @@ -/usr/bin/swift \ No newline at end of file diff --git a/Tests/PathKitTests/PathKitSpec.swift b/Tests/PathKitTests/PathKitSpec.swift deleted file mode 100644 index 7abab91..0000000 --- a/Tests/PathKitTests/PathKitSpec.swift +++ /dev/null @@ -1,527 +0,0 @@ -import Foundation -import Spectre -@testable import PathKit - - -struct ThrowError: Error, Equatable {} -func == (lhs:ThrowError, rhs:ThrowError) -> Bool { return true } - - -public func testPathKit() { -describe("PathKit") { - let filePath = #file - let fixtures = Path(filePath).parent() + "Fixtures" - - $0.before { - Path.current = Path(filePath).parent() - } - - $0.it("provides the system separator") { - try expect(Path.separator) == "/" - } - - $0.it("returns the current working directory") { - try expect(Path.current.description) == FileManager().currentDirectoryPath - } - - $0.describe("initialisation") { - $0.it("can be initialised with no arguments") { - try expect(Path().description) == "" - } - - $0.it("can be initialised with a string") { - let path = Path("/usr/bin/swift") - try expect(path.description) == "/usr/bin/swift" - } - - $0.it("can be initialised with path components") { - let path = Path(components: ["/usr", "bin", "swift"]) - try expect(path.description) == "/usr/bin/swift" - } - } - - $0.describe("convertable") { - $0.it("can be converted from a string literal") { - let path: Path = "/usr/bin/swift" - try expect(path.description) == "/usr/bin/swift" - } - - $0.it("can be converted to a string description") { - try expect(Path("/usr/bin/swift").description) == "/usr/bin/swift" - } - - $0.it("can be converted to a string") { - try expect(Path("/usr/bin/swift").string) == "/usr/bin/swift" - } - - $0.it("can be converted to a url") { - try expect(Path("/usr/bin/swift").url) == URL(fileURLWithPath: "/usr/bin/swift") - } - } - - $0.describe("Equatable") { - $0.it("equates to an equal path") { - try expect(Path("/usr")) == Path("/usr") - } - - $0.it("doesn't equate to a non-equal path") { - try expect(Path("/usr")) != Path("/bin") - } - } - - $0.describe("Hashable") { - $0.it("exposes a hash value identical to an identical path") { - try expect(Path("/usr").hashValue) == Path("/usr").hashValue - } - } - - $0.context("Absolute") { - $0.describe("a relative path") { - let path = Path("swift") - - $0.it("can be converted to an absolute path") { - try expect(path.absolute()) == (Path.current + Path("swift")) - } - - $0.it("is not absolute") { - try expect(path.isAbsolute) == false - } - - $0.it("is relative") { - try expect(path.isRelative) == true - } - } - - $0.describe("a relative path with tilde") { - let path = Path("~") - - $0.it("can be converted to an absolute path") { - #if os(Linux) - if NSUserName() == "root" { - try expect(path.absolute()) == "/root" - } - else { - try expect(path.absolute()) == "/home/" + NSUserName() - } - #else - try expect(path.absolute()) == "/Users/" + NSUserName() - #endif - } - - $0.it("is not absolute") { - try expect(path.isAbsolute) == false - } - - $0.it("is relative") { - try expect(path.isRelative) == true - } - - } - - $0.describe("an absolute path") { - let path = Path("/usr/bin/swift") - - $0.it("can be converted to an absolute path") { - try expect(path.absolute()) == path - } - - $0.it("is absolute") { - try expect(path.isAbsolute) == true - } - - $0.it("is not relative") { - try expect(path.isRelative) == false - } - } - } - - $0.it("can be normalized") { - let path = Path("/usr/./local/../bin/swift") - try expect(path.normalize()) == Path("/usr/bin/swift") - } - - $0.it("can be abbreviated") { - let home = Path.home.string - - try expect(Path("\(home)/foo/bar").abbreviate()) == Path("~/foo/bar") - try expect(Path("\(home)").abbreviate()) == Path("~") - try expect(Path("\(home)/").abbreviate()) == Path("~") - try expect(Path("\(home)/backups\(home)").abbreviate()) == Path("~/backups\(home)") - try expect(Path("\(home)/backups\(home)/foo/bar").abbreviate()) == Path("~/backups\(home)/foo/bar") - - #if os(Linux) - try expect(Path("\(home.uppercased())").abbreviate()) == Path("\(home.uppercased())") - #else - try expect(Path("\(home.uppercased())").abbreviate()) == Path("~") - #endif - } - - struct FakeFSInfo: FileSystemInfo { - let caseSensitive: Bool - - func isFSCaseSensitiveAt(path: Path) -> Bool { - return caseSensitive - } - } - - $0.it("can abbreviate paths on a case sensitive fs") { - let home = Path.home.string - let fakeFSInfo = FakeFSInfo(caseSensitive: true) - let path = Path("\(home.uppercased())", fileSystemInfo: fakeFSInfo) - - try expect(path.abbreviate().string) == home.uppercased() - } - - $0.it("can abbreviate paths on a case insensitive fs") { - let home = Path.home.string - let fakeFSInfo = FakeFSInfo(caseSensitive: false) - let path = Path("\(home.uppercased())", fileSystemInfo: fakeFSInfo) - - try expect(path.abbreviate()) == Path("~") - } - - $0.describe("symlinking") { - $0.it("can create a symlink with a relative destination") { - let path = fixtures + "symlinks/file" - let resolvedPath = try path.symlinkDestination() - try expect(resolvedPath.normalize()) == fixtures + "file" - } - - $0.it("can create a symlink with an absolute destination") { - let path = fixtures + "symlinks/swift" - let resolvedPath = try path.symlinkDestination() - try expect(resolvedPath) == Path("/usr/bin/swift") - } - - $0.it("can create a relative symlink in the same directory") { - #if os(Linux) - throw skip() - #else - let path = fixtures + "symlinks/same-dir" - let resolvedPath = try path.symlinkDestination() - try expect(resolvedPath.normalize()) == fixtures + "symlinks/file" - #endif - } - } - - $0.it("can return the last component") { - try expect(Path("a/b/c.d").lastComponent) == "c.d" - try expect(Path("a/..").lastComponent) == ".." - } - - $0.it("can return the last component without extension") { - try expect(Path("a/b/c.d").lastComponentWithoutExtension) == "c" - try expect(Path("a/..").lastComponentWithoutExtension) == ".." - } - - $0.it("can be split into components") { - try expect(Path("a/b/c.d").components) == ["a", "b", "c.d"] - try expect(Path("/a/b/c.d").components) == ["/", "a", "b", "c.d"] - try expect(Path("~/a/b/c.d").components) == ["~", "a", "b", "c.d"] - } - - $0.it("can return the extension") { - try expect(Path("a/b/c.d").`extension`) == "d" - try expect(Path("a/b.c.d").`extension`) == "d" - try expect(Path("a/b").`extension`).to.beNil() - } - - $0.describe("exists") { - $0.it("can check if the path exists") { - try expect(fixtures.exists).to.beTrue() - } - - $0.it("can check if a path does not exist") { - let path = Path("/pathkit/test") - try expect(path.exists).to.beFalse() - } - } - - $0.describe("file info") { - $0.it("can test if a path is a directory") { - try expect((fixtures + "directory").isDirectory).to.beTrue() - try expect((fixtures + "symlinks/directory").isDirectory).to.beTrue() - } - - $0.it("can test if a path is a symlink") { - try expect((fixtures + "file/file").isSymlink).to.beFalse() - try expect((fixtures + "symlinks/file").isSymlink).to.beTrue() - } - - $0.it("can test if a path is a file") { - try expect((fixtures + "file").isFile).to.beTrue() - try expect((fixtures + "symlinks/file").isFile).to.beTrue() - } - - $0.it("can test if a path is executable") { - try expect((fixtures + "permissions/executable").isExecutable).to.beTrue() - } - - $0.it("can test if a path is readable") { - try expect((fixtures + "permissions/readable").isReadable).to.beTrue() - } - - $0.it("can test if a path is writable") { - try expect((fixtures + "permissions/writable").isWritable).to.beTrue() - } - - // fatal error: isDeletableFile(atPath:) is not yet implemented - $0.it("can test if a path is deletable") { - #if os(Linux) - throw skip() - #else - try expect((fixtures + "permissions/deletable").isDeletable).to.beTrue() - #endif - } - } - - $0.describe("changing directory") { - $0.it("can change directory") { - let current = Path.current - - try Path("/usr/bin").chdir { - try expect(Path.current) == Path("/usr/bin") - } - - try expect(Path.current) == current - } - - $0.it("can change directory with a throwing closure") { - let current = Path.current - let error = ThrowError() - - try expect { - try Path("/usr/bin").chdir { - try expect(Path.current) == Path("/usr/bin") - throw error - } - }.toThrow(error) - - try expect(Path.current) == current - } - } - - $0.describe("special paths") { - $0.it("can provide the home directory") { - try expect(Path.home) == Path("~").normalize() - } - - $0.it("can provide the tempoary directory") { - try expect(Path.temporary) == Path(NSTemporaryDirectory()) - try expect(Path.temporary.exists).to.beTrue() - } - } - - $0.describe("reading") { - $0.it("can read Data from a file") { - let path = fixtures + "hello" - let contents: Data? = try path.read() - let string = NSString(data:contents! as Data, encoding: String.Encoding.utf8.rawValue)! - - try expect(string) == "Hello World\n" - } - - $0.it("errors when you read from a non-existing file as NSData") { - let path = Path("/tmp/pathkit-testing") - - try expect { - try path.read() as Data - }.toThrow() - } - - $0.it("can read a String from a file") { - let path = fixtures + "hello" - let contents: String? = try path.read() - - try expect(contents) == "Hello World\n" - } - - $0.it("errors when you read from a non-existing file as a String") { - let path = Path("/tmp/pathkit-testing") - - try expect { - try path.read() as String - }.toThrow() - } - } - - $0.describe("writing") { - $0.it("can write NSData to a file") { - let path = Path("/tmp/pathkit-testing") - let data = "Hi".data(using: String.Encoding.utf8, allowLossyConversion: true) - - try expect(path.exists).to.beFalse() - - try path.write(data!) - try expect(try? path.read()) == "Hi" - try path.delete() - } - - $0.it("throws an error on failure writing data") { - #if os(Linux) - throw skip() - #else - let path = Path("/") - let data = "Hi".data(using: String.Encoding.utf8, allowLossyConversion: true) - - try expect({ - try path.write(data!) - }).toThrow() - #endif - } - - $0.it("can write a String to a file") { - let path = Path("/tmp/pathkit-testing") - - try path.write("Hi") - try expect(try path.read()) == "Hi" - try path.delete() - } - - $0.it("throws an error on failure writing a String") { - #if os(Linux) - throw skip() - #else - let path = Path("/") - - try expect({ - try path.write("hi") - }).toThrow() - #endif - } - } - - $0.it("can return the parent directory of a path") { - try expect((fixtures + "directory/child").parent()) == fixtures + "directory" - try expect((fixtures + "symlinks/directory").parent()) == fixtures + "symlinks" - try expect((fixtures + "directory/..").parent()) == fixtures + "directory/../.." - try expect(Path("/").parent()) == "/" - } - - $0.it("can return the children") { - let children = try fixtures.children().sorted(by: <) - let expected = ["hello", "directory", "file", "permissions", "symlinks"].map { fixtures + $0 }.sorted(by: <) - try expect(children) == expected - } - - $0.it("can return the recursive children") { - let parent = fixtures + "directory" - let children = try parent.recursiveChildren().sorted(by: <) - let expected = [".hiddenFile", "child", "subdirectory", "subdirectory/child"].map { parent + $0 }.sorted(by: <) - try expect(children) == expected - } - - $0.describe("conforms to SequenceType") { - $0.it("without options") { - let path = fixtures + "directory" - var children = ["child", "subdirectory", ".hiddenFile"].map { path + $0 } - let generator = path.makeIterator() - while let child = generator.next() { - generator.skipDescendants() - if let index = children.firstIndex(of: child) { - children.remove(at: index) - } else { - throw failure("Generated unexpected element: <\(child)>") - } - } - - try expect(children.isEmpty).to.beTrue() - try expect(Path("/non/existing/directory/path").makeIterator().next()).to.beNil() - } - - $0.it("with options") { - #if os(Linux) - throw skip() - #else - let path = fixtures + "directory" - var children = ["child", "subdirectory"].map { path + $0 } - let generator = path.iterateChildren(options: .skipsHiddenFiles).makeIterator() - while let child = generator.next() { - generator.skipDescendants() - if let index = children.firstIndex(of: child) { - children.remove(at: index) - } else { - throw failure("Generated unexpected element: <\(child)>") - } - } - - try expect(children.isEmpty).to.beTrue() - try expect(Path("/non/existing/directory/path").makeIterator().next()).to.beNil() - #endif - } - } - - $0.it("can be pattern matched") { - try expect(Path("/var") ~= "~").to.beFalse() - try expect(Path("/Users") ~= "/Users").to.beTrue() - try expect((Path.home + "..") ~= "~/..").to.beTrue() - } - - $0.it("can be compared") { - try expect(Path("a")) < Path("b") - } - - $0.it("can be appended to") { - // Trivial cases. - try expect(Path("a/b")) == "a" + "b" - try expect(Path("a/b")) == "a/" + "b" - - // Appending (to) absolute paths - try expect(Path("/")) == "/" + "/" - try expect(Path("/")) == "/" + ".." - try expect(Path("/a")) == "/" + "../a" - try expect(Path("/b")) == "a" + "/b" - - // Appending (to) '.' - try expect(Path("a")) == "a" + "." - try expect(Path("a")) == "a" + "./." - try expect(Path("a")) == "." + "a" - try expect(Path("a")) == "./." + "a" - try expect(Path(".")) == "." + "." - try expect(Path(".")) == "./." + "./." - try expect(Path("../a")) == "." + "./../a" - try expect(Path("../a")) == "." + "../a" - - // Appending (to) '..' - try expect(Path(".")) == "a" + ".." - try expect(Path("a")) == "a/b" + ".." - try expect(Path("../..")) == ".." + ".." - try expect(Path("b")) == "a" + "../b" - try expect(Path("a/c")) == "a/b" + "../c" - try expect(Path("a/b/d/e")) == "a/b/c" + "../d/e" - try expect(Path("../../a")) == ".." + "../a" - } - - $0.describe("glob") { - $0.it("Path static glob") { - let pattern = (fixtures + "permissions/*able").description - let paths = Path.glob(pattern) - - let results = try (fixtures + "permissions").children().map { $0.absolute() }.sorted(by: <) - try expect(paths) == results.sorted(by: <) - } - - $0.it("can glob inside a directory") { - let paths = fixtures.glob("permissions/*able") - - let results = try (fixtures + "permissions").children().map { $0.absolute() }.sorted(by: <) - try expect(paths) == results.sorted(by: <) - } - } - - $0.describe("#match") { - $0.it("can match pattern against relative path") { - try expect(Path("test.txt").match("test.txt")).to.beTrue() - try expect(Path("test.txt").match("*.txt")).to.beTrue() - try expect(Path("test.txt").match("*")).to.beTrue() - try expect(Path("test.txt").match("test.md")).to.beFalse() - } - - $0.it("can match pattern against absolute path") { - try expect(Path("/home/kyle/test.txt").match("*.txt")).to.beTrue() - try expect(Path("/home/kyle/test.txt").match("/home/*.txt")).to.beTrue() - try expect(Path("/home/kyle/test.txt").match("*.md")).to.beFalse() - } - } -} -} diff --git a/Tests/PathKitTests/PathKitTests.swift b/Tests/PathKitTests/PathKitTests.swift new file mode 100644 index 0000000..d043035 --- /dev/null +++ b/Tests/PathKitTests/PathKitTests.swift @@ -0,0 +1,555 @@ +// PathKitTests.swift +// PathKit +// +// Copyright (c) 2014, Kyle Fuller +// All rights reserved. +// Version 1.0.1 +// +// Copyright © 2024 MFB Technologies, Inc. All rights reserved. +// After Version 1.0.1 +// +// This source code is licensed under the BSD-2-Clause License found in the +// LICENSE file in the root directory of this source tree. + +import Foundation +@testable import PathKit +import XCTest + +final class PathKitTests: XCTestCase { + let (fixtures, fixturesResolved) = { + let fixtures: String + let fixturesResolved: String + if let tempDirFromEnv = ProcessInfo.processInfo.environment["TEMP_DIR"] { + fixtures = "\(tempDirFromEnv)/PathKitTests" + fixturesResolved = "\(tempDirFromEnv)/PathKitTests" + } else { + fixtures = "/tmp/PathKitTests" + #if os(Linux) + fixturesResolved = "/tmp/PathKitTests" + #else + fixturesResolved = "/private/tmp/PathKitTests" + #endif + } + return (fixtures, fixturesResolved) + }() + + override func tearDown() async throws { + if FileManager.default.fileExists(atPath: fixtures) { + try FileManager.default.removeItem(atPath: fixtures) + } + try await super.tearDown() + } + + func setupFixtures() throws { + let fileManager = FileManager.default + + if fileManager.fileExists(atPath: fixtures) { + try fileManager.removeItem(atPath: fixtures) + } + + let targetDir = fixtures + let directoryDir = "\(targetDir)/directory" + let subDirectoryDir = "\(targetDir)/directory/subdirectory" + let permissionsDir = "\(targetDir)/permissions" + let symlinksDir = "\(targetDir)/symlinks" + + try fileManager.createDirectory(atPath: targetDir, withIntermediateDirectories: true) + try fileManager.createDirectory(atPath: directoryDir, withIntermediateDirectories: true) + try fileManager.createDirectory(atPath: subDirectoryDir, withIntermediateDirectories: true) + try fileManager.createDirectory(atPath: permissionsDir, withIntermediateDirectories: true) + try fileManager.createDirectory(atPath: symlinksDir, withIntermediateDirectories: true) + + fileManager.createFile(atPath: "\(targetDir)/file", contents: nil) + fileManager.createFile(atPath: "\(targetDir)/hello", contents: "Hello World\n".data(using: .utf8)) + fileManager.createFile(atPath: "\(directoryDir)/child", contents: nil) + fileManager.createFile(atPath: "\(directoryDir)/.hiddenFile", contents: nil) + fileManager.createFile(atPath: "\(subDirectoryDir)/child", contents: nil) + + fileManager.createFile(atPath: "\(permissionsDir)/deletable", contents: nil) + try fileManager.setAttributes( + [FileAttributeKey.posixPermissions: 0o222], + ofItemAtPath: "\(permissionsDir)/deletable" + ) + + fileManager.createFile(atPath: "\(permissionsDir)/executable", contents: nil) + try fileManager.setAttributes( + [FileAttributeKey.posixPermissions: 0o111], + ofItemAtPath: "\(permissionsDir)/executable" + ) + + fileManager.createFile(atPath: "\(permissionsDir)/readable", contents: nil) + try fileManager.setAttributes( + [FileAttributeKey.posixPermissions: 0o444], + ofItemAtPath: "\(permissionsDir)/readable" + ) + + fileManager.createFile(atPath: "\(permissionsDir)/writable", contents: nil) + try fileManager.setAttributes( + [FileAttributeKey.posixPermissions: 0o222], + ofItemAtPath: "\(permissionsDir)/writable" + ) + + fileManager.createFile(atPath: "\(permissionsDir)/none", contents: nil) + try fileManager.setAttributes( + [FileAttributeKey.posixPermissions: 0o000], + ofItemAtPath: "\(permissionsDir)/none" + ) + + try fileManager.createSymbolicLink(atPath: "\(symlinksDir)/directory", withDestinationPath: directoryDir) + try fileManager.createSymbolicLink(atPath: "\(symlinksDir)/same-dir", withDestinationPath: symlinksDir) + try fileManager.createSymbolicLink(atPath: "\(symlinksDir)/swift", withDestinationPath: "/usr/bin/swift") + try fileManager.createSymbolicLink(atPath: "\(symlinksDir)/file", withDestinationPath: "\(targetDir)/file") + } + + func testSystemSeparator() throws { + XCTAssertEqual(Path.separator, "/") + } + + func testCurrentWorkingDirectory() throws { + XCTAssertEqual(Path.current.description, FileManager.default.currentDirectoryPath) + } + + func testEmptyInitialization() throws { + XCTAssertEqual(Path().description, "") + } + + func testStringInitialization() throws { + let path = Path("/usr/bin/swift") + XCTAssertEqual(path.description, "/usr/bin/swift") + } + + func testComponentsInitialization() throws { + let path = Path(components: ["/usr", "bin", "swift"]) + XCTAssertEqual(path.description, "/usr/bin/swift") + } + + func testConversionToUrl() throws { + let path = Path("/usr/bin/swift") + try XCTAssertEqual(path.url, XCTUnwrap(URL(fileURLWithPath: "/usr/bin/swift"))) + } + + func testEquatableConformance() throws { + let path = Path("/usr/bin/swift") + XCTAssertEqual(path, Path("/usr/bin/swift")) + XCTAssertNotEqual(path, Path("/usr/bin/rust")) + } + + func testHashableConformance() throws { + let path = Path("/usr/bin/swift") + XCTAssertEqual(path.hashValue, Path("/usr/bin/swift").hashValue) + XCTAssertNotEqual(path.hashValue, Path("/usr/bin/rust").hashValue) + XCTAssertNotEqual(path.hashValue, path.description.hashValue) + } + + func testRelativePath() throws { + let path = Path("swift") + XCTAssertTrue(path.isRelative) + XCTAssertFalse(path.isAbsolute) + } + + func testRelativePathWithTilde() throws { + let path = Path("~") + XCTAssertTrue(path.isRelative) + XCTAssertFalse(path.isAbsolute) + } + + func testConvertRelativeToAbsolute() throws { + let path = Path("swift") + XCTAssertEqual(path.absolute(), Path.current + Path("swift")) + } + + func testConvertRelativeToAbsoluteWithTilde() throws { + let path = Path("~") + #if os(Linux) + if let envHome = ProcessInfo.processInfo.environment["HOME"] { + XCTAssertEqual(path.absolute().string, envHome) + } else if NSUserName() == "root" { + XCTAssertEqual(path.absolute(), "/root") + } else { + XCTAssertEqual(path.absolute(), "/home/" + NSUserName()) + } + #elseif os(macOS) + XCTAssertEqual(path.absolute(), "/Users/" + NSUserName()) + #endif + XCTAssertTrue(path.isRelative) + XCTAssertFalse(path.isAbsolute) + } + + func testAbsolutePath() throws { + let path = Path("/usr/bin/swift") + XCTAssertEqual(path.absolute(), path) + XCTAssertTrue(path.isAbsolute) + XCTAssertFalse(path.isRelative) + } + + func testNormalization() throws { + let path = Path("/usr/./local/../bin/swift") + XCTAssertEqual(path.normalize(), Path("/usr/bin/swift")) + } + + func testAbbreviation() throws { + let home = Path.home.string + XCTAssertEqual(Path("\(home)/foo/bar").abbreviate(), Path("~/foo/bar")) + XCTAssertEqual(Path("\(home)").abbreviate(), Path("~")) + XCTAssertEqual(Path("\(home)/").abbreviate(), Path("~")) + XCTAssertEqual(Path("\(home)/backups\(home)").abbreviate(), Path("~/backups\(home)")) + XCTAssertEqual(Path("\(home)/backups\(home)/foo/bar").abbreviate(), Path("~/backups\(home)/foo/bar")) + #if os(Linux) + XCTAssertEqual(Path("\(home.uppercased())").abbreviate(), Path("\(home.uppercased())")) + #else + XCTAssertEqual(Path("\(home.uppercased())").abbreviate(), Path("~")) + #endif + } + + struct FakeFSInfo: FileSystemInfo { + let caseSensitive: Bool + + func isFSCaseSensitiveAt(path _: Path) -> Bool { + caseSensitive + } + } + + func testAbbreviationOnCaseSensitiveFileSystem() throws { + let home = Path.home.string + let fakeFsInfo = FakeFSInfo(caseSensitive: true) + let path = Path("\(home.uppercased())", fileSystemInfo: fakeFsInfo) + XCTAssertEqual(path.abbreviate().string, home.uppercased()) + } + + func testAbbreviationOnCaseInsensitiveFileSystem() throws { + let home = Path.home.string + let fakeFsInfo = FakeFSInfo(caseSensitive: false) + let path = Path("\(home.uppercased())", fileSystemInfo: fakeFsInfo) + XCTAssertEqual(path.abbreviate(), Path("~")) + } + + func testCreateSymlinkWithRelativeDestination() throws { + try setupFixtures() + let path = Path("\(fixtures)/symlinks/file") + let resolvedPath = try path.symlinkDestination() + XCTAssertEqual(resolvedPath.normalize(), fixtures + "file") + } + + func testCreateSymlinkWithAbsoluteDestination() throws { + try setupFixtures() + let path = Path("\(fixtures)/symlinks/swift") + let resolvedPath = try path.symlinkDestination() + XCTAssertEqual(resolvedPath.normalize(), Path("/usr/bin/swift")) + } + + func testCreateSymlinkWithSameDirectory() throws { + #if os(macOS) + try setupFixtures() + let path = Path("\(fixtures)/symlinks/same-dir") + let resolvedPath = try path.symlinkDestination() + XCTAssertEqual(resolvedPath.normalize(), fixtures + "symlinks") + #endif + } + + func testReturnLastComponent() throws { + XCTAssertEqual(Path("a/b/c.d").lastComponent, "c.d") + XCTAssertEqual(Path("a/..").lastComponent, "..") + } + + func testReturnLastComponentWithoutExtension() throws { + XCTAssertEqual(Path("a/b/c.d").lastComponentWithoutExtension, "c") + XCTAssertEqual(Path("a/..").lastComponentWithoutExtension, "..") + } + + func testSplitIntoComponents() throws { + XCTAssertEqual(Path("a/b/c.d").components, ["a", "b", "c.d"]) + XCTAssertEqual(Path("/a/b/c.d").components, ["/", "a", "b", "c.d"]) + XCTAssertEqual(Path("~/a/b/c.d").components, ["~", "a", "b", "c.d"]) + } + + func testReturnExtension() throws { + XCTAssertEqual(Path("a/b/c.d").extension, "d") + XCTAssertEqual(Path("a/b.c.d").extension, "d") + XCTAssertNil(Path("a/b").extension) + } + + func testCheckIfPathExists() throws { + try setupFixtures() + XCTAssertTrue(Path(fixtures).exists) + } + + func testCheckIfPathDoesNotExist() throws { + try setupFixtures() + XCTAssertFalse(Path("/pathkit/test").exists) + } + + func testCheckIfPathIsDirectory() throws { + try setupFixtures() + XCTAssertTrue(Path("\(fixtures)/directory").isDirectory) + XCTAssertTrue(Path("\(fixtures)/symlinks/directory").isDirectory) + XCTAssertFalse(Path("\(fixtures)/file").isDirectory) + XCTAssertFalse(Path("\(fixtures)/symlinks/file").isDirectory) + } + + func testCheckIfPathIsSymlink() throws { + try setupFixtures() + XCTAssertFalse(Path("\(fixtures)/file/file").isSymlink) + XCTAssertTrue(Path("\(fixtures)/symlinks/file").isSymlink) + } + + func testCheckIfPathIsFile() throws { + try setupFixtures() + XCTAssertTrue(Path("\(fixtures)/file").isFile) + XCTAssertTrue(Path("\(fixtures)/symlinks/file").isFile) + XCTAssertFalse(Path("\(fixtures)/directory").isFile) + XCTAssertFalse(Path("\(fixtures)/symlinks/directory").isFile) + } + + func testCheckIfPathIsExecutable() throws { + try setupFixtures() + XCTAssertTrue(Path("\(fixtures)/permissions/executable").isExecutable) + XCTAssertFalse(Path("\(fixtures)/permissions/writable").isExecutable) + } + + func testCheckIfPathIsReadable() throws { + try setupFixtures() + XCTAssertTrue(Path("\(fixtures)/permissions/readable").isReadable) + XCTAssertFalse(Path("\(fixtures)/permissions/none").isReadable) + } + + func testCheckIfPathIsWritable() throws { + try setupFixtures() + XCTAssertTrue(Path("\(fixtures)/permissions/writable").isWritable) + XCTAssertFalse(Path("\(fixtures)/permissions/readable").isWritable) + } + + func testCheckIfPathIsDeletable() throws { + #if os(macOS) + try setupFixtures() + XCTAssertTrue(Path("\(fixtures)/permissions/deletable").isDeletable) + #endif + } + + func testChangeDirectory() throws { + let current = Path.current + + Path("/usr/bin").chdir { + XCTAssertEqual(Path.current, Path("/usr/bin")) + } + + XCTAssertEqual(Path.current, current) + } + + func testChangeDirectoryWithThrowingClosure() throws { + let current = Path.current + let error = ThrowError() + + XCTAssertThrowsError(try Path("/usr/bin").chdir { + XCTAssertEqual(Path.current, Path("/usr/bin")) + throw error + }) + + XCTAssertEqual(Path.current, current) + } + + func testProvideHomeDirectory() throws { + XCTAssertEqual(Path.home, Path("~").normalize()) + } + + func testProvideTempDirectory() throws { + XCTAssertEqual(Path.temporary, Path(NSTemporaryDirectory())) + XCTAssertTrue(Path.temporary.exists) + } + + func testReadDataFromFile() throws { + try setupFixtures() + let contents = try XCTUnwrap(Path("\(fixtures)/hello").read()) + let string = try XCTUnwrap(String(data: contents, encoding: .utf8)) + XCTAssertEqual(string, "Hello World\n") + } + + func testReadDataFromNonexistentFileFails() throws { + let path = Path("\(fixtures)/pathkit-testing") + try XCTAssertThrowsError(path.read() as Data) + } + + func testReadStringFromFile() throws { + try setupFixtures() + let contents: String = try XCTUnwrap(Path("\(fixtures)/hello").read()) + XCTAssertEqual(contents, "Hello World\n") + } + + func testReadStringFromNonexistentFileFails() throws { + let path = Path("\(fixtures)/pathkit-testing") + try XCTAssertThrowsError(path.read() as String) + } + + func testWriteDataToFile() throws { + try setupFixtures() + let path = Path("\(fixtures)/pathkit-testing") + let data = try XCTUnwrap("Hi".data(using: String.Encoding.utf8, allowLossyConversion: true)) + + XCTAssertFalse(path.exists) + + try path.write(data) + try XCTAssertEqual(path.read(), "Hi") + } + + func testWriteDataToFileFailure() throws { + #if os(macOS) + try setupFixtures() + let path = Path("/") + let data = try XCTUnwrap("Hi".data(using: String.Encoding.utf8, allowLossyConversion: true)) + + try XCTAssertThrowsError(path.write(data)) + #endif + } + + func testWriteStringToFile() throws { + try setupFixtures() + let path = Path("\(fixtures)/pathkit-testing") + + try path.write("Hi") + try XCTAssertEqual(path.read(), "Hi") + } + + func testWriteStringToFileFailure() throws { + #if os(macOS) + try setupFixtures() + let path = Path("/") + + try XCTAssertThrowsError(path.write("Hi")) + #endif + } + + func testReturnParentDirectory() throws { + try setupFixtures() + XCTAssertEqual((fixtures + "directory/child").parent(), fixtures + "directory") + XCTAssertEqual((fixtures + "symlinks/directory").parent(), fixtures + "symlinks") + XCTAssertEqual((fixtures + "directory/..").parent(), fixtures + "directory/../..") + XCTAssertEqual(Path("/").parent(), "/") + } + + func testReturnChildren() throws { + try setupFixtures() + let children = try Path(fixtures).children().sorted(by: <) + let expected = ["hello", "directory", "file", "permissions", "symlinks"].map { Path(fixtures) + $0 } + .sorted(by: <) + XCTAssertEqual(children, expected) + } + + func testReturnChildrenRecursively() throws { + try setupFixtures() + let children = try Path("\(fixtures)/directory").recursiveChildren().sorted(by: <) + let expected = [".hiddenFile", "child", "subdirectory", "subdirectory/child"] + .map { Path("\(fixtures)/directory") + $0 }.sorted(by: <) + XCTAssertEqual(children, expected) + } + + func testConformsToSequenceWithoutOptions() throws { + try setupFixtures() + let path = Path("\(fixturesResolved)/directory") + XCTAssertTrue(path.exists) + XCTAssertTrue(path.isDirectory) + try XCTAssertEqual(path.children().count, 3) + var children = ["child", "subdirectory", ".hiddenFile"].map { path + $0 } + let generator = path.makeIterator() + while let child = generator.next() { + generator.skipDescendants() + if let index = children.firstIndex(of: child) { + children.remove(at: index) + } else { + throw ThrowError() + } + } + + XCTAssertTrue(children.isEmpty) + XCTAssertNil(Path("/non/existing/directory/path").makeIterator().next()) + } + + func testConformsToSequenceWithOptions() throws { + #if os(macOS) + try setupFixtures() + let path = Path("\(fixturesResolved)/directory") + var children = ["child", "subdirectory"].map { path + $0 } + let generator = path.iterateChildren(options: .skipsHiddenFiles).makeIterator() + while let child = generator.next() { + generator.skipDescendants() + if let index = children.firstIndex(of: child) { + children.remove(at: index) + } else { + throw ThrowError() + } + } + + XCTAssertTrue(children.isEmpty) + XCTAssertNil(Path("/non/existing/directory/path").makeIterator().next()) + #endif + } + + func testPatternMatching() throws { + XCTAssertFalse(Path("/var") ~= "~") + XCTAssertTrue(Path("/Users") ~= "/Users") + XCTAssertTrue((Path.home + "..") ~= "~/..") + } + + func testComparison() throws { + XCTAssertTrue(Path("a") < Path("b")) + } + + func testAppend() throws { + // Trivial cases. + XCTAssertEqual(Path("a/b"), "a" + "b") + XCTAssertEqual(Path("a/b"), "a/" + "b") + + // Appending (to) absolute paths + XCTAssertEqual(Path("/"), "/" + "/") + XCTAssertEqual(Path("/"), "/" + "..") + XCTAssertEqual(Path("/a"), "/" + "../a") + XCTAssertEqual(Path("/b"), "a" + "/b") + + // Appending (to) '.' + XCTAssertEqual(Path("a"), "a" + ".") + XCTAssertEqual(Path("a"), "a" + "./.") + XCTAssertEqual(Path("a"), "." + "a") + XCTAssertEqual(Path("a"), "./." + "a") + XCTAssertEqual(Path("."), "." + ".") + XCTAssertEqual(Path("."), "./." + "./.") + XCTAssertEqual(Path("../a"), "." + "./../a") + XCTAssertEqual(Path("../a"), "." + "../a") + + // Appending (to) '..' + XCTAssertEqual(Path("."), "a" + "..") + XCTAssertEqual(Path("a"), "a/b" + "..") + XCTAssertEqual(Path("../.."), ".." + "..") + XCTAssertEqual(Path("b"), "a" + "../b") + XCTAssertEqual(Path("a/c"), "a/b" + "../c") + XCTAssertEqual(Path("a/b/d/e"), "a/b/c" + "../d/e") + XCTAssertEqual(Path("../../a"), ".." + "../a") + } + + func testPathStaticGlob() throws { + try setupFixtures() + let pattern = Path("\(fixtures)/permissions/*").description + let paths = Path.glob(pattern) + + let results = try Path("\(fixtures)/permissions").children().map { $0.absolute() }.sorted(by: <) + XCTAssertEqual(paths, results.sorted(by: <)) + } + + func testGlobInsideDirectory() throws { + try setupFixtures() + let paths = Path(fixtures).glob("permissions/*") + + let results = try Path("\(fixtures)/permissions").children().map { $0.absolute() }.sorted(by: <) + XCTAssertEqual(paths, results.sorted(by: <)) + } + + func testPatternMatchAgainstRelativePath() throws { + XCTAssertTrue(Path("test.txt").match("test.txt")) + XCTAssertTrue(Path("test.txt").match("*.txt")) + XCTAssertTrue(Path("test.txt").match("*")) + XCTAssertFalse(Path("test.txt").match("test.md")) + } + + func testPatternMatchAgainstAbsolutePath() throws { + XCTAssertTrue(Path("/home/kyle/test.txt").match("*.txt")) + XCTAssertTrue(Path("/home/kyle/test.txt").match("/home/*.txt")) + XCTAssertFalse(Path("/home/kyle/test.txt").match("*.md")) + } +} diff --git a/Tests/PathKitTests/ThrowError.swift b/Tests/PathKitTests/ThrowError.swift new file mode 100644 index 0000000..b412731 --- /dev/null +++ b/Tests/PathKitTests/ThrowError.swift @@ -0,0 +1,14 @@ +// ThrowError.swift +// PathKit +// +// Copyright (c) 2014, Kyle Fuller +// All rights reserved. +// Version 1.0.1 +// +// Copyright © 2024 MFB Technologies, Inc. All rights reserved. +// After Version 1.0.1 +// +// This source code is licensed under the BSD-2-Clause License found in the +// LICENSE file in the root directory of this source tree. + +struct ThrowError: Error, Equatable {} diff --git a/Tests/PathKitTests/XCTest.swift b/Tests/PathKitTests/XCTest.swift deleted file mode 100644 index 6d6fee2..0000000 --- a/Tests/PathKitTests/XCTest.swift +++ /dev/null @@ -1,7 +0,0 @@ -import XCTest - -class PathKitTests: XCTestCase { - func testRunSpectre() { - testPathKit() - } -}