diff --git a/.github/jazzy.yml b/.github/jazzy.yml index 88d484d..2655109 100644 --- a/.github/jazzy.yml +++ b/.github/jazzy.yml @@ -3,7 +3,7 @@ custom_categories: - name: Path children: - Path - - /(_:_:) + - Pathish xcodebuild_arguments: - UseModernBuildSystem=NO output: diff --git a/Sources/Path+Attributes.swift b/Sources/Path+Attributes.swift index ab1d5e2..c6a8471 100644 --- a/Sources/Path+Attributes.swift +++ b/Sources/Path+Attributes.swift @@ -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 file’s attributes using UNIX octal notation. @@ -40,6 +63,8 @@ public extension Pathish { try FileManager.default.setAttributes([.posixPermissions: octal], ofItemAtPath: string) return Path(self) } + + //MARK: Filesystem Locking /** Applies the macOS filesystem “lock” attribute. @@ -83,24 +108,17 @@ public extension Pathish { #endif 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 { - enum Kind { - case file, symlink, directory + /// A filesystem entry’s kind, file, directory, symlink etc. + enum EntryType: CaseIterable { + /// The entry is a file. + case file + /// The entry is a symlink. + case symlink + /// The entry is a directory. + case directory } } diff --git a/Sources/Path+CommonDirectories.swift b/Sources/Path+CommonDirectories.swift index da10973..6d12fd6 100644 --- a/Sources/Path+CommonDirectories.swift +++ b/Sources/Path+CommonDirectories.swift @@ -1,8 +1,9 @@ import Foundation +/// The `extension` that provides static properties that are common directories. extension Path { //MARK: Common Directories - + /// Returns a `Path` containing `FileManager.default.currentDirectoryPath`. public static var cwd: DynamicPath { return .init(string: FileManager.default.currentDirectoryPath) diff --git a/Sources/Path+FileManager.swift b/Sources/Path+FileManager.swift index 8ca9eff..83b4e8b 100644 --- a/Sources/Path+FileManager.swift +++ b/Sources/Path+FileManager.swift @@ -4,8 +4,9 @@ import Glibc #endif public extension Pathish { + //MARK: File Management - + /** Copies a file. @@ -25,7 +26,7 @@ public extension Pathish { */ @discardableResult func copy(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) } #if os(Linux) && !swift(>=5.2) // check if fixed @@ -61,11 +62,11 @@ public extension Pathish { */ @discardableResult func copy(into: P, overwrite: Bool = false) throws -> Path { - if into.kind == nil { + if into.type == nil { try into.mkdir(.p) } 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) } #if os(Linux) && !swift(>=5.2) // check if fixed @@ -95,7 +96,7 @@ public extension Pathish { */ @discardableResult func move(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.moveItem(at: url, to: to.url) @@ -119,13 +120,13 @@ public extension Pathish { */ @discardableResult func move(into: P, overwrite: Bool = false) throws -> Path { - switch into.kind { + switch into.type { case nil: try into.mkdir(.p) fallthrough case .directory?: 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.moveItem(at: url, to: rv.url) @@ -147,7 +148,7 @@ public extension Pathish { */ @inlinable func delete() throws { - if kind != nil { + if type != nil { try FileManager.default.removeItem(at: url) } } @@ -159,7 +160,7 @@ public extension Pathish { @inlinable @discardableResult func touch() throws -> Path { - if kind == nil { + if type == nil { guard FileManager.default.createFile(atPath: string, contents: nil) else { throw CocoaError.error(.fileWriteUnknown) } @@ -233,7 +234,7 @@ public extension Pathish { */ @discardableResult func symlink(into dir: P) throws -> Path { - switch dir.kind { + switch dir.type { case nil, .symlink?: try dir.mkdir(.p) fallthrough diff --git a/Sources/Path+ls.swift b/Sources/Path+ls.swift index 0bd04fc..9ef5c14 100644 --- a/Sources/Path+ls.swift +++ b/Sources/Path+ls.swift @@ -5,33 +5,104 @@ public extension Path { class Finder { fileprivate init(path: Path) { self.path = path + self.enumerator = FileManager.default.enumerator(atPath: path.string) } /// The `path` find operations operate on. 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 = 1...Int.max + /// The kinds of filesystem entries find operations will return. - fileprivate(set) public var kinds: Set? + public var types: Set { + return _types ?? Set(EntryType.allCases) + } + + private var _types: Set? + /// The file extensions find operations will return. Files *and* directories unless you filter for `kinds`. - fileprivate(set) public var extensions: Set? + private(set) public var extensions: Set? } } +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 { - /// Multiple calls will configure the Finder for the final depth call only. - func maxDepth(_ maxDepth: Int) -> Path.Finder { + /// A max depth of `0` returns only the path we are searching, `1` is that directory’s listing. + func depth(max maxDepth: Int) -> Path.Finder { #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 - 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 directory’s 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 directory’s listing. + /// A min depth of `0` also returns the path we are searching, `1` is that directory’s listing. Default is `1` thus not returning ourself. + func depth(_ rng: Range) -> 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 directory’s listing. + /// A min depth of `0` also returns the path we are searching, `1` is that directory’s listing. Default is `1` thus not returning ourself. + func depth(_ rng: ClosedRange) -> Path.Finder { + #if os(Linux) && !swift(>=5.0) + fputs("warning: depth not implemented for Swift < 5\n", stderr) + #endif + depth = rng return self } /// Multiple calls will configure the Finder with multiple kinds. - func kind(_ kind: Path.Kind) -> Path.Finder { - kinds = kinds ?? [] - kinds!.insert(kind) + func type(_ type: Path.EntryType) -> Path.Finder { + _types = _types ?? [] + _types!.insert(type) return self } @@ -42,13 +113,6 @@ public extension Path.Finder { 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` enum ControlFlow { /// Stop enumerating this directory, return to the parent. @@ -61,34 +125,23 @@ public extension Path.Finder { /// Enumerate, one file at a time. func execute(_ closure: (Path) throws -> ControlFlow) rethrows { - guard let finder = FileManager.default.enumerator(atPath: path.string) else { - 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 } - + while let path = next() { switch try closure(path) { case .skip: - finder.skipDescendants() + enumerator.skipDescendants() case .abort: return case .continue: - break + continue } } } } public extension Pathish { + + //MARK: Directory Listing + /** 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. @@ -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 { /// Filters the list of entries to be a list of Paths that are directories. Symlinks to directories are not returned. 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. var files: [Path] { return filter { - $0.exists && !$0.isDirectory + switch $0.type { + case .none, .directory?: + return false + case .file?, .symlink?: + return true + } } } } diff --git a/Sources/Path->Bool.swift b/Sources/Path->Bool.swift index 7e2f5ae..87c2c11 100644 --- a/Sources/Path->Bool.swift +++ b/Sources/Path->Bool.swift @@ -6,6 +6,7 @@ import Darwin #endif public extension Pathish { + //MARK: Filesystem Properties /** diff --git a/Sources/Path.swift b/Sources/Path.swift index c140d5d..e58c518 100644 --- a/Sources/Path.swift +++ b/Sources/Path.swift @@ -136,6 +136,8 @@ public struct Path: Pathish { } public extension Pathish { + //MARK: Filesystem Representation + /// Returns a `URL` representing this file path. var url: URL { return URL(fileURLWithPath: string) @@ -201,14 +203,14 @@ public extension Pathish { /** 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 don’t do this because we are *always* absolute paths. */ @inlinable 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. @@ -405,7 +407,7 @@ private func join_(prefix: String, pathComponents: S) -> String where S: Sequ 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 public struct DynamicPath: Pathish { /// The normalized string representation of the underlying filesystem path @@ -417,7 +419,7 @@ public struct DynamicPath: Pathish { } /// Converts a `Path` to a `DynamicPath` - public init(_ path: Path) { + public init(_ path: P) { string = path.string } diff --git a/Sources/Pathish.swift b/Sources/Pathish.swift index 992d2bc..2d621af 100644 --- a/Sources/Pathish.swift +++ b/Sources/Pathish.swift @@ -1,16 +1,6 @@ - /// 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 public protocol Pathish: Hashable, Comparable { /// The normalized string representation of the underlying filesystem path 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 == (lhs: Self, rhs: P) -> Bool { - return lhs.string == rhs.string - } -} diff --git a/Tests/PathTests/PathTests+ls().swift b/Tests/PathTests/PathTests+ls().swift index e7957c7..cff02b5 100644 --- a/Tests/PathTests/PathTests+ls().swift +++ b/Tests/PathTests/PathTests+ls().swift @@ -2,39 +2,50 @@ import XCTest import Path extension PathTests { - func testFindMaxDepth0() throws { - #if !os(Linux) || swift(>=5) + func testFindMaxDepth1() throws { try Path.mktemp { tmpdir in try tmpdir.a.touch() try tmpdir.b.touch() try tmpdir.c.mkdir().join("e").touch() - XCTAssertEqual( - Set(tmpdir.find().maxDepth(0).execute()), - Set([tmpdir.a, tmpdir.b, tmpdir.c].map(Path.init))) + do { + let finder = tmpdir.find().depth(max: 1) + 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 { - #if !os(Linux) || swift(>=5) + func testFindMaxDepth2() throws { try Path.mktemp { tmpdir in try tmpdir.a.touch() try tmpdir.b.mkdir().join("c").touch() try tmpdir.b.d.mkdir().join("e").touch() - #if !os(Linux) - XCTAssertEqual( - Set(tmpdir.find().maxDepth(1).execute()), - Set([tmpdir.a, tmpdir.b, tmpdir.b.c].map(Path.init))) - #else - // Linux behavior is different :-/ - XCTAssertEqual( - Set(tmpdir.find().maxDepth(1).execute()), - Set([tmpdir.a, tmpdir.b, tmpdir.b.d, tmpdir.b.c].map(Path.init))) - #endif + do { + let finder = tmpdir.find().depth(max: 2) + XCTAssertEqual(finder.depth, 1...2) + XCTAssertEqual( + Set(finder), + Set([tmpdir.a, tmpdir.b, tmpdir.b.d, tmpdir.b.c].map(Path.init))) + } + do { + let finder = tmpdir.find().depth(max: 3) + 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 { @@ -43,10 +54,10 @@ extension PathTests { try tmpdir.join("bar.txt").touch() XCTAssertEqual( - Set(tmpdir.find().extension("json").execute()), + Set(tmpdir.find().extension("json")), [tmpdir.join("foo.json")]) XCTAssertEqual( - Set(tmpdir.find().extension("txt").extension("json").execute()), + Set(tmpdir.find().extension("txt").extension("json")), [tmpdir.join("foo.json"), tmpdir.join("bar.txt")]) } } @@ -57,13 +68,13 @@ extension PathTests { try tmpdir.bar.touch() XCTAssertEqual( - Set(tmpdir.find().kind(.file).execute()), + Set(tmpdir.find().type(.file)), [tmpdir.join("bar")]) XCTAssertEqual( - Set(tmpdir.find().kind(.directory).execute()), + Set(tmpdir.find().type(.directory)), [tmpdir.join("foo")]) XCTAssertEqual( - Set(tmpdir.find().kind(.file).kind(.directory).execute()), + Set(tmpdir.find().type(.file).type(.directory)), Set(["foo", "bar"].map(tmpdir.join))) } } diff --git a/Tests/PathTests/PathTests.swift b/Tests/PathTests/PathTests.swift index b16b07d..c9a7566 100644 --- a/Tests/PathTests/PathTests.swift +++ b/Tests/PathTests/PathTests.swift @@ -76,10 +76,10 @@ class PathTests: XCTestCase { try Path.mktemp { tmpdir in XCTAssertTrue(tmpdir.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(tmpdir.bar.kind == .file) - XCTAssertTrue(tmpdir.kind == .directory) + XCTAssertTrue(tmpdir.bar.type == .file) + XCTAssertTrue(tmpdir.type == .directory) } } @@ -393,18 +393,18 @@ class PathTests: XCTestCase { // regression test: can delete a symlink that points to a non-existent file let bar5 = try tmpdir.bar4.symlink(as: tmpdir.bar5) - XCTAssertEqual(bar5.kind, .symlink) + XCTAssertEqual(bar5.type, .symlink) XCTAssertFalse(bar5.exists) XCTAssertNoThrow(try bar5.delete()) - XCTAssertEqual(bar5.kind, nil) + XCTAssertEqual(bar5.type, nil) // test that deleting a symlink *only* deletes the symlink let bar7 = try tmpdir.bar6.touch().symlink(as: tmpdir.bar7) - XCTAssertEqual(bar7.kind, .symlink) + XCTAssertEqual(bar7.type, .symlink) XCTAssertTrue(bar7.exists) XCTAssertNoThrow(try bar7.delete()) - XCTAssertEqual(bar7.kind, nil) - XCTAssertEqual(tmpdir.bar6.kind, .file) + XCTAssertEqual(bar7.type, nil) + XCTAssertEqual(tmpdir.bar6.type, .file) } } @@ -619,8 +619,8 @@ class PathTests: XCTestCase { } func testPathComponents() throws { - XCTAssertEqual(Path.root.foo.bar.components, ["/", "foo", "bar"]) - XCTAssertEqual(Path.root.components, ["/"]) + XCTAssertEqual(Path.root.foo.bar.components, ["foo", "bar"]) + XCTAssertEqual(Path.root.components, []) } func testFlatMap() throws { @@ -637,9 +637,9 @@ class PathTests: XCTestCase { try Path.mktemp { tmpdir in let foo = try tmpdir.foo.touch() let bar = try foo.symlink(as: tmpdir.bar) - XCTAssertEqual(tmpdir.kind, .directory) - XCTAssertEqual(foo.kind, .file) - XCTAssertEqual(bar.kind, .symlink) + XCTAssertEqual(tmpdir.type, .directory) + XCTAssertEqual(foo.type, .file) + XCTAssertEqual(bar.type, .symlink) } } }