Finder is a iterable Sequence; .type -> .kind

This commit is contained in:
Max Howell
2019-07-21 22:43:43 -04:00
parent dfad7367b7
commit 0ef50dff2e
10 changed files with 199 additions and 117 deletions

2
.github/jazzy.yml vendored
View File

@@ -3,7 +3,7 @@ custom_categories:
- name: Path - name: Path
children: children:
- Path - Path
- /(_:_:) - Pathish
xcodebuild_arguments: xcodebuild_arguments:
- UseModernBuildSystem=NO - UseModernBuildSystem=NO
output: output:

View File

@@ -30,6 +30,29 @@ public extension Pathish {
} }
} }
/// The type of the entry.
/// - SeeAlso: `Path.EntryType`
@available(*, deprecated, message: "- SeeAlso: Path.type")
var kind: Path.EntryType? {
return type
}
/// The type of the entry.
/// - SeeAlso: `Path.EntryType`
var type: Path.EntryType? {
var buf = stat()
guard lstat(string, &buf) == 0 else {
return nil
}
if buf.st_mode & S_IFMT == S_IFLNK {
return .symlink
} else if buf.st_mode & S_IFMT == S_IFDIR {
return .directory
} else {
return .file
}
}
/** /**
Sets the files attributes using UNIX octal notation. Sets the files attributes using UNIX octal notation.
@@ -40,6 +63,8 @@ public extension Pathish {
try FileManager.default.setAttributes([.posixPermissions: octal], ofItemAtPath: string) try FileManager.default.setAttributes([.posixPermissions: octal], ofItemAtPath: string)
return Path(self) return Path(self)
} }
//MARK: Filesystem Locking
/** /**
Applies the macOS filesystem lock attribute. Applies the macOS filesystem lock attribute.
@@ -83,24 +108,17 @@ public extension Pathish {
#endif #endif
return Path(self) return Path(self)
} }
var kind: Path.Kind? {
var buf = stat()
guard lstat(string, &buf) == 0 else {
return nil
}
if buf.st_mode & S_IFMT == S_IFLNK {
return .symlink
} else if buf.st_mode & S_IFMT == S_IFDIR {
return .directory
} else {
return .file
}
}
} }
/// The `extension` that provides `Kind`.
public extension Path { public extension Path {
enum Kind { /// A filesystem entrys kind, file, directory, symlink etc.
case file, symlink, directory enum EntryType: CaseIterable {
/// The entry is a file.
case file
/// The entry is a symlink.
case symlink
/// The entry is a directory.
case directory
} }
} }

View File

@@ -1,8 +1,9 @@
import Foundation import Foundation
/// The `extension` that provides static properties that are common directories.
extension Path { extension Path {
//MARK: Common Directories //MARK: Common Directories
/// Returns a `Path` containing `FileManager.default.currentDirectoryPath`. /// Returns a `Path` containing `FileManager.default.currentDirectoryPath`.
public static var cwd: DynamicPath { public static var cwd: DynamicPath {
return .init(string: FileManager.default.currentDirectoryPath) return .init(string: FileManager.default.currentDirectoryPath)

View File

@@ -4,8 +4,9 @@ import Glibc
#endif #endif
public extension Pathish { public extension Pathish {
//MARK: File Management //MARK: File Management
/** /**
Copies a file. Copies a file.
@@ -25,7 +26,7 @@ public extension Pathish {
*/ */
@discardableResult @discardableResult
func copy<P: Pathish>(to: P, overwrite: Bool = false) throws -> Path { func copy<P: Pathish>(to: P, overwrite: Bool = false) throws -> Path {
if overwrite, let tokind = to.kind, tokind != .directory, kind != .directory { if overwrite, let tokind = to.type, tokind != .directory, type != .directory {
try FileManager.default.removeItem(at: to.url) try FileManager.default.removeItem(at: to.url)
} }
#if os(Linux) && !swift(>=5.2) // check if fixed #if os(Linux) && !swift(>=5.2) // check if fixed
@@ -61,11 +62,11 @@ public extension Pathish {
*/ */
@discardableResult @discardableResult
func copy<P: Pathish>(into: P, overwrite: Bool = false) throws -> Path { func copy<P: Pathish>(into: P, overwrite: Bool = false) throws -> Path {
if into.kind == nil { if into.type == nil {
try into.mkdir(.p) try into.mkdir(.p)
} }
let rv = into/basename() let rv = into/basename()
if overwrite, let kind = rv.kind, kind != .directory { if overwrite, let kind = rv.type, kind != .directory {
try FileManager.default.removeItem(at: rv.url) try FileManager.default.removeItem(at: rv.url)
} }
#if os(Linux) && !swift(>=5.2) // check if fixed #if os(Linux) && !swift(>=5.2) // check if fixed
@@ -95,7 +96,7 @@ public extension Pathish {
*/ */
@discardableResult @discardableResult
func move<P: Pathish>(to: P, overwrite: Bool = false) throws -> Path { func move<P: Pathish>(to: P, overwrite: Bool = false) throws -> Path {
if overwrite, let kind = to.kind, kind != .directory { if overwrite, let kind = to.type, kind != .directory {
try FileManager.default.removeItem(at: to.url) try FileManager.default.removeItem(at: to.url)
} }
try FileManager.default.moveItem(at: url, to: to.url) try FileManager.default.moveItem(at: url, to: to.url)
@@ -119,13 +120,13 @@ public extension Pathish {
*/ */
@discardableResult @discardableResult
func move<P: Pathish>(into: P, overwrite: Bool = false) throws -> Path { func move<P: Pathish>(into: P, overwrite: Bool = false) throws -> Path {
switch into.kind { switch into.type {
case nil: case nil:
try into.mkdir(.p) try into.mkdir(.p)
fallthrough fallthrough
case .directory?: case .directory?:
let rv = into/basename() let rv = into/basename()
if overwrite, let rvkind = rv.kind, rvkind != .directory { if overwrite, let rvkind = rv.type, rvkind != .directory {
try FileManager.default.removeItem(at: rv.url) try FileManager.default.removeItem(at: rv.url)
} }
try FileManager.default.moveItem(at: url, to: rv.url) try FileManager.default.moveItem(at: url, to: rv.url)
@@ -147,7 +148,7 @@ public extension Pathish {
*/ */
@inlinable @inlinable
func delete() throws { func delete() throws {
if kind != nil { if type != nil {
try FileManager.default.removeItem(at: url) try FileManager.default.removeItem(at: url)
} }
} }
@@ -159,7 +160,7 @@ public extension Pathish {
@inlinable @inlinable
@discardableResult @discardableResult
func touch() throws -> Path { func touch() throws -> Path {
if kind == nil { if type == nil {
guard FileManager.default.createFile(atPath: string, contents: nil) else { guard FileManager.default.createFile(atPath: string, contents: nil) else {
throw CocoaError.error(.fileWriteUnknown) throw CocoaError.error(.fileWriteUnknown)
} }
@@ -233,7 +234,7 @@ public extension Pathish {
*/ */
@discardableResult @discardableResult
func symlink<P: Pathish>(into dir: P) throws -> Path { func symlink<P: Pathish>(into dir: P) throws -> Path {
switch dir.kind { switch dir.type {
case nil, .symlink?: case nil, .symlink?:
try dir.mkdir(.p) try dir.mkdir(.p)
fallthrough fallthrough

View File

@@ -5,33 +5,104 @@ public extension Path {
class Finder { class Finder {
fileprivate init(path: Path) { fileprivate init(path: Path) {
self.path = path self.path = path
self.enumerator = FileManager.default.enumerator(atPath: path.string)
} }
/// The `path` find operations operate on. /// The `path` find operations operate on.
public let path: Path public let path: Path
/// The maximum directory depth find operations will dip. Zero means no subdirectories.
fileprivate(set) public var maxDepth: Int? = nil private let enumerator: FileManager.DirectoryEnumerator!
/// The range of directory depths for which the find operation will return entries.b
private(set) public var depth: ClosedRange<Int> = 1...Int.max
/// The kinds of filesystem entries find operations will return. /// The kinds of filesystem entries find operations will return.
fileprivate(set) public var kinds: Set<Path.Kind>? public var types: Set<EntryType> {
return _types ?? Set(EntryType.allCases)
}
private var _types: Set<EntryType>?
/// The file extensions find operations will return. Files *and* directories unless you filter for `kinds`. /// The file extensions find operations will return. Files *and* directories unless you filter for `kinds`.
fileprivate(set) public var extensions: Set<String>? private(set) public var extensions: Set<String>?
} }
} }
extension Path.Finder: Sequence, IteratorProtocol {
public func next() -> Path? {
guard let enumerator = enumerator else {
return nil
}
while let relativePath = enumerator.nextObject() as? String {
let path = self.path/relativePath
#if !os(Linux) || swift(>=5.0)
if enumerator.level > depth.upperBound {
enumerator.skipDescendants()
continue
}
if enumerator.level < depth.lowerBound {
if path == self.path, depth.lowerBound == 0 {
return path
} else {
continue
}
}
#endif
if let type = path.type, !types.contains(type) { continue }
if let exts = extensions, !exts.contains(path.extension) { continue }
return path
}
return nil
}
public typealias Element = Path
}
public extension Path.Finder { public extension Path.Finder {
/// Multiple calls will configure the Finder for the final depth call only. /// A max depth of `0` returns only the path we are searching, `1` is that directorys listing.
func maxDepth(_ maxDepth: Int) -> Path.Finder { func depth(max maxDepth: Int) -> Path.Finder {
#if os(Linux) && !swift(>=5.0) #if os(Linux) && !swift(>=5.0)
fputs("warning: maxDepth not implemented for Swift < 5\n", stderr) fputs("warning: depth not implemented for Swift < 5\n", stderr)
#endif #endif
self.maxDepth = maxDepth depth = Swift.min(maxDepth, depth.lowerBound)...maxDepth
return self
}
/// A min depth of `0` also returns the path we are searching, `1` is that directorys listing. Default is `1` thus not returning ourself.
func depth(min minDepth: Int) -> Path.Finder {
#if os(Linux) && !swift(>=5.0)
fputs("warning: depth not implemented for Swift < 5\n", stderr)
#endif
depth = minDepth...Swift.max(depth.upperBound, minDepth)
return self
}
/// A max depth of `0` returns only the path we are searching, `1` is that directorys listing.
/// A min depth of `0` also returns the path we are searching, `1` is that directorys listing. Default is `1` thus not returning ourself.
func depth(_ rng: Range<Int>) -> Path.Finder {
#if os(Linux) && !swift(>=5.0)
fputs("warning: depth not implemented for Swift < 5\n", stderr)
#endif
depth = rng.lowerBound...(rng.upperBound - 1)
return self
}
/// A max depth of `0` returns only the path we are searching, `1` is that directorys listing.
/// A min depth of `0` also returns the path we are searching, `1` is that directorys listing. Default is `1` thus not returning ourself.
func depth(_ rng: ClosedRange<Int>) -> Path.Finder {
#if os(Linux) && !swift(>=5.0)
fputs("warning: depth not implemented for Swift < 5\n", stderr)
#endif
depth = rng
return self return self
} }
/// Multiple calls will configure the Finder with multiple kinds. /// Multiple calls will configure the Finder with multiple kinds.
func kind(_ kind: Path.Kind) -> Path.Finder { func type(_ type: Path.EntryType) -> Path.Finder {
kinds = kinds ?? [] _types = _types ?? []
kinds!.insert(kind) _types!.insert(type)
return self return self
} }
@@ -42,13 +113,6 @@ public extension Path.Finder {
return self return self
} }
/// Enumerate and return all results, note that this may take a while since we are recursive.
func execute() -> [Path] {
var rv: [Path] = []
execute{ rv.append($0); return .continue }
return rv
}
/// The return type for `Path.Finder` /// The return type for `Path.Finder`
enum ControlFlow { enum ControlFlow {
/// Stop enumerating this directory, return to the parent. /// Stop enumerating this directory, return to the parent.
@@ -61,34 +125,23 @@ public extension Path.Finder {
/// Enumerate, one file at a time. /// Enumerate, one file at a time.
func execute(_ closure: (Path) throws -> ControlFlow) rethrows { func execute(_ closure: (Path) throws -> ControlFlow) rethrows {
guard let finder = FileManager.default.enumerator(atPath: path.string) else { while let path = next() {
fputs("warning: could not enumerate: \(path)\n", stderr)
return
}
while let relativePath = finder.nextObject() as? String {
#if !os(Linux) || swift(>=5.0)
if let maxDepth = maxDepth, finder.level > maxDepth {
finder.skipDescendants()
}
#endif
let path = self.path/relativePath
if path == self.path { continue }
if let kinds = kinds, let kind = path.kind, !kinds.contains(kind) { continue }
if let exts = extensions, !exts.contains(path.extension) { continue }
switch try closure(path) { switch try closure(path) {
case .skip: case .skip:
finder.skipDescendants() enumerator.skipDescendants()
case .abort: case .abort:
return return
case .continue: case .continue:
break continue
} }
} }
} }
} }
public extension Pathish { public extension Pathish {
//MARK: Directory Listing
/** /**
Same as the `ls` command output is shallow and unsorted. Same as the `ls` command output is shallow and unsorted.
- Note: as per `ls`, by default we do *not* return hidden files. Specify `.a` for hidden files. - Note: as per `ls`, by default we do *not* return hidden files. Specify `.a` for hidden files.
@@ -114,7 +167,7 @@ public extension Pathish {
} }
} }
/// Convenience functions for the arraies of `Path` /// Convenience functions for the arrays of `Path`
public extension Array where Element == Path { public extension Array where Element == Path {
/// Filters the list of entries to be a list of Paths that are directories. Symlinks to directories are not returned. /// Filters the list of entries to be a list of Paths that are directories. Symlinks to directories are not returned.
var directories: [Path] { var directories: [Path] {
@@ -127,7 +180,12 @@ public extension Array where Element == Path {
/// - Note: symlinks that point to files that do not exist are *not* returned. /// - Note: symlinks that point to files that do not exist are *not* returned.
var files: [Path] { var files: [Path] {
return filter { return filter {
$0.exists && !$0.isDirectory switch $0.type {
case .none, .directory?:
return false
case .file?, .symlink?:
return true
}
} }
} }
} }

View File

@@ -6,6 +6,7 @@ import Darwin
#endif #endif
public extension Pathish { public extension Pathish {
//MARK: Filesystem Properties //MARK: Filesystem Properties
/** /**

View File

@@ -136,6 +136,8 @@ public struct Path: Pathish {
} }
public extension Pathish { public extension Pathish {
//MARK: Filesystem Representation
/// Returns a `URL` representing this file path. /// Returns a `URL` representing this file path.
var url: URL { var url: URL {
return URL(fileURLWithPath: string) return URL(fileURLWithPath: string)
@@ -201,14 +203,14 @@ public extension Pathish {
/** /**
Splits the string representation on the directory separator. Splits the string representation on the directory separator.
- Important: The first element is always "/" to be consistent with `NSString.pathComponents`. - Important: `NSString.pathComponents` will always return an initial `/` in its array for absolute paths to indicate that the path was absolute, we dont do this because we are *always* absolute paths.
*/ */
@inlinable @inlinable
var components: [String] { var components: [String] {
return ["/"] + string.split(separator: "/").map(String.init) return string.split(separator: "/").map(String.init)
} }
//MARK: Pathing //MARK:- Pathing
/** /**
Joins a path and a string to produce a new path. Joins a path and a string to produce a new path.
@@ -405,7 +407,7 @@ private func join_<S>(prefix: String, pathComponents: S) -> String where S: Sequ
return rv return rv
} }
/// A path that supports arbituary dot notation, eg. Path.root.usr.bin /// A path that supports arbituary dot notation, eg. `Path.root.usr.bin`
@dynamicMemberLookup @dynamicMemberLookup
public struct DynamicPath: Pathish { public struct DynamicPath: Pathish {
/// The normalized string representation of the underlying filesystem path /// The normalized string representation of the underlying filesystem path
@@ -417,7 +419,7 @@ public struct DynamicPath: Pathish {
} }
/// Converts a `Path` to a `DynamicPath` /// Converts a `Path` to a `DynamicPath`
public init(_ path: Path) { public init<P: Pathish>(_ path: P) {
string = path.string string = path.string
} }

View File

@@ -1,16 +1,6 @@
/// A type that represents a filesystem path, if you conform your type /// A type that represents a filesystem path, if you conform your type
/// to `Pathish` it is your responsibility to ensure the string is correctly normalized /// to `Pathish` it is your responsibility to ensure the string is correctly normalized
public protocol Pathish: Hashable, Comparable { public protocol Pathish: Hashable, Comparable {
/// The normalized string representation of the underlying filesystem path /// The normalized string representation of the underlying filesystem path
var string: String { get } var string: String { get }
} }
public extension Pathish {
/// Two `Path`s are equal if their strings are identical. Strings are normalized upon construction, yet
/// if the files are different symlinks to the same file the equality check will not succeed. Use `realpath`
/// in such circumstances.
static func ==<P: Pathish> (lhs: Self, rhs: P) -> Bool {
return lhs.string == rhs.string
}
}

View File

@@ -2,39 +2,50 @@ import XCTest
import Path import Path
extension PathTests { extension PathTests {
func testFindMaxDepth0() throws { func testFindMaxDepth1() throws {
#if !os(Linux) || swift(>=5)
try Path.mktemp { tmpdir in try Path.mktemp { tmpdir in
try tmpdir.a.touch() try tmpdir.a.touch()
try tmpdir.b.touch() try tmpdir.b.touch()
try tmpdir.c.mkdir().join("e").touch() try tmpdir.c.mkdir().join("e").touch()
XCTAssertEqual( do {
Set(tmpdir.find().maxDepth(0).execute()), let finder = tmpdir.find().depth(max: 1)
Set([tmpdir.a, tmpdir.b, tmpdir.c].map(Path.init))) XCTAssertEqual(finder.depth, 1...1)
#if !os(Linux) || swift(>=5)
XCTAssertEqual(Set(finder), Set([tmpdir.a, tmpdir.b, tmpdir.c].map(Path.init)))
#endif
}
do {
let finder = tmpdir.find().depth(max: 0)
XCTAssertEqual(finder.depth, 0...0)
#if !os(Linux) || swift(>=5)
XCTAssertEqual(Set(finder), Set())
#endif
}
} }
#endif
} }
func testFindMaxDepth1() throws { func testFindMaxDepth2() throws {
#if !os(Linux) || swift(>=5)
try Path.mktemp { tmpdir in try Path.mktemp { tmpdir in
try tmpdir.a.touch() try tmpdir.a.touch()
try tmpdir.b.mkdir().join("c").touch() try tmpdir.b.mkdir().join("c").touch()
try tmpdir.b.d.mkdir().join("e").touch() try tmpdir.b.d.mkdir().join("e").touch()
#if !os(Linux) do {
XCTAssertEqual( let finder = tmpdir.find().depth(max: 2)
Set(tmpdir.find().maxDepth(1).execute()), XCTAssertEqual(finder.depth, 1...2)
Set([tmpdir.a, tmpdir.b, tmpdir.b.c].map(Path.init))) XCTAssertEqual(
#else Set(finder),
// Linux behavior is different :-/ Set([tmpdir.a, tmpdir.b, tmpdir.b.d, tmpdir.b.c].map(Path.init)))
XCTAssertEqual( }
Set(tmpdir.find().maxDepth(1).execute()), do {
Set([tmpdir.a, tmpdir.b, tmpdir.b.d, tmpdir.b.c].map(Path.init))) let finder = tmpdir.find().depth(max: 3)
#endif XCTAssertEqual(finder.depth, 1...3)
XCTAssertEqual(
Set(finder),
Set([tmpdir.a, tmpdir.b, tmpdir.b.d, tmpdir.b.c, tmpdir.b.d.e].map(Path.init)))
}
} }
#endif
} }
func testFindExtension() throws { func testFindExtension() throws {
@@ -43,10 +54,10 @@ extension PathTests {
try tmpdir.join("bar.txt").touch() try tmpdir.join("bar.txt").touch()
XCTAssertEqual( XCTAssertEqual(
Set(tmpdir.find().extension("json").execute()), Set(tmpdir.find().extension("json")),
[tmpdir.join("foo.json")]) [tmpdir.join("foo.json")])
XCTAssertEqual( XCTAssertEqual(
Set(tmpdir.find().extension("txt").extension("json").execute()), Set(tmpdir.find().extension("txt").extension("json")),
[tmpdir.join("foo.json"), tmpdir.join("bar.txt")]) [tmpdir.join("foo.json"), tmpdir.join("bar.txt")])
} }
} }
@@ -57,13 +68,13 @@ extension PathTests {
try tmpdir.bar.touch() try tmpdir.bar.touch()
XCTAssertEqual( XCTAssertEqual(
Set(tmpdir.find().kind(.file).execute()), Set(tmpdir.find().type(.file)),
[tmpdir.join("bar")]) [tmpdir.join("bar")])
XCTAssertEqual( XCTAssertEqual(
Set(tmpdir.find().kind(.directory).execute()), Set(tmpdir.find().type(.directory)),
[tmpdir.join("foo")]) [tmpdir.join("foo")])
XCTAssertEqual( XCTAssertEqual(
Set(tmpdir.find().kind(.file).kind(.directory).execute()), Set(tmpdir.find().type(.file).type(.directory)),
Set(["foo", "bar"].map(tmpdir.join))) Set(["foo", "bar"].map(tmpdir.join)))
} }
} }

View File

@@ -76,10 +76,10 @@ class PathTests: XCTestCase {
try Path.mktemp { tmpdir in try Path.mktemp { tmpdir in
XCTAssertTrue(tmpdir.exists) XCTAssertTrue(tmpdir.exists)
XCTAssertFalse(try tmpdir.bar.symlink(as: tmpdir.foo).exists) XCTAssertFalse(try tmpdir.bar.symlink(as: tmpdir.foo).exists)
XCTAssertTrue(tmpdir.foo.kind == .symlink) XCTAssertTrue(tmpdir.foo.type == .symlink)
XCTAssertTrue(try tmpdir.bar.touch().symlink(as: tmpdir.baz).exists) XCTAssertTrue(try tmpdir.bar.touch().symlink(as: tmpdir.baz).exists)
XCTAssertTrue(tmpdir.bar.kind == .file) XCTAssertTrue(tmpdir.bar.type == .file)
XCTAssertTrue(tmpdir.kind == .directory) XCTAssertTrue(tmpdir.type == .directory)
} }
} }
@@ -393,18 +393,18 @@ class PathTests: XCTestCase {
// regression test: can delete a symlink that points to a non-existent file // regression test: can delete a symlink that points to a non-existent file
let bar5 = try tmpdir.bar4.symlink(as: tmpdir.bar5) let bar5 = try tmpdir.bar4.symlink(as: tmpdir.bar5)
XCTAssertEqual(bar5.kind, .symlink) XCTAssertEqual(bar5.type, .symlink)
XCTAssertFalse(bar5.exists) XCTAssertFalse(bar5.exists)
XCTAssertNoThrow(try bar5.delete()) XCTAssertNoThrow(try bar5.delete())
XCTAssertEqual(bar5.kind, nil) XCTAssertEqual(bar5.type, nil)
// test that deleting a symlink *only* deletes the symlink // test that deleting a symlink *only* deletes the symlink
let bar7 = try tmpdir.bar6.touch().symlink(as: tmpdir.bar7) let bar7 = try tmpdir.bar6.touch().symlink(as: tmpdir.bar7)
XCTAssertEqual(bar7.kind, .symlink) XCTAssertEqual(bar7.type, .symlink)
XCTAssertTrue(bar7.exists) XCTAssertTrue(bar7.exists)
XCTAssertNoThrow(try bar7.delete()) XCTAssertNoThrow(try bar7.delete())
XCTAssertEqual(bar7.kind, nil) XCTAssertEqual(bar7.type, nil)
XCTAssertEqual(tmpdir.bar6.kind, .file) XCTAssertEqual(tmpdir.bar6.type, .file)
} }
} }
@@ -619,8 +619,8 @@ class PathTests: XCTestCase {
} }
func testPathComponents() throws { func testPathComponents() throws {
XCTAssertEqual(Path.root.foo.bar.components, ["/", "foo", "bar"]) XCTAssertEqual(Path.root.foo.bar.components, ["foo", "bar"])
XCTAssertEqual(Path.root.components, ["/"]) XCTAssertEqual(Path.root.components, [])
} }
func testFlatMap() throws { func testFlatMap() throws {
@@ -637,9 +637,9 @@ class PathTests: XCTestCase {
try Path.mktemp { tmpdir in try Path.mktemp { tmpdir in
let foo = try tmpdir.foo.touch() let foo = try tmpdir.foo.touch()
let bar = try foo.symlink(as: tmpdir.bar) let bar = try foo.symlink(as: tmpdir.bar)
XCTAssertEqual(tmpdir.kind, .directory) XCTAssertEqual(tmpdir.type, .directory)
XCTAssertEqual(foo.kind, .file) XCTAssertEqual(foo.type, .file)
XCTAssertEqual(bar.kind, .symlink) XCTAssertEqual(bar.type, .symlink)
} }
} }
} }