From d2bb2a1fdc140d7f3f7669cdb8e268f39230c87a Mon Sep 17 00:00:00 2001 From: Max Howell Date: Sun, 21 Jul 2019 11:19:34 -0400 Subject: [PATCH] Path.find() --- README.md | 35 +++++- Sources/Path+ls.swift | 152 +++++++++++++++++++++----- Tests/PathTests/PathTests+ls().swift | 70 ++++++++++++ Tests/PathTests/XCTestManifests.swift | 4 + 4 files changed, 230 insertions(+), 31 deletions(-) create mode 100644 Tests/PathTests/PathTests+ls().swift diff --git a/README.md b/README.md index 751634d..6a437cf 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,8 @@ try Bundle.main.resources.join("foo").copy(to: .home) ## Directory listings We provide `ls()`, called because it behaves like the Terminal `ls` function, -the name thus implies its behavior, ie. that it is not recursive. +the name thus implies its behavior, ie. that it is not recursive and doesn’t +list hidden files. ```swift for entry in Path.home.ls() { @@ -169,6 +170,38 @@ let files = Path.home.ls().files let swiftFiles = Path.home.ls().files(withExtension: "swift") ``` +We provide `find()` for recursive listing: + +```swift +Path.home.find().execute { path in + //… +} +``` + +Which is configurable: + +```swift +Path.home.find().maxDepth(1).extension("swift").kind(.file) { path in + //… +} +``` + +And can be controlled: + +```swift +Path.home.find().execute { path in + guard foo else { return .skip } + guard bar else { return .abort } + return .continue +} +``` + +Or just get all paths at once: + +```swift +let paths = Path.home.find().execute() +``` + # `Path.swift` is robust Some parts of `FileManager` are not exactly idiomatic. For example diff --git a/Sources/Path+ls.swift b/Sources/Path+ls.swift index 1f7dc6b..294dfde 100644 --- a/Sources/Path+ls.swift +++ b/Sources/Path+ls.swift @@ -1,49 +1,135 @@ import Foundation -/** - A file entry from a directory listing. - - SeeAlso: `ls()` -*/ -public struct Entry { - /// The kind of this directory entry. - public enum Kind { - /// The path is a file. - case file - /// The path is a directory. - case directory +public extension Path { + /** + A file entry from a directory listing. + - SeeAlso: `ls()` + */ + struct Entry { + /// The kind of this directory entry. + public enum Kind { + /// The path is a file. + case file + /// The path is a directory. + case directory + } + /// The kind of this entry. + public let kind: Kind + /// The path of this entry. + public let path: Path + } + + class Finder { + fileprivate init(path: Path) { + self.path = path + } + + public let path: Path + fileprivate(set) public var maxDepth: Int? = nil + fileprivate(set) public var kinds: Set? + fileprivate(set) public var extensions: Set? } - /// The kind of this entry. - public let kind: Kind - /// The path of this entry. - public let path: Path } -public extension Path { +public extension Path.Finder { + /// Multiple calls will configure the Finder for the final depth call only. + func maxDepth(_ maxDepth: Int) -> Path.Finder { + #if os(Linux) && !swift(>=5.0) + fputs("warning: maxDepth not implemented for Swift < 5\n", stderr) + #endif + self.maxDepth = maxDepth + return self + } + + /// Multiple calls will configure the Finder with multiple kinds. + func kind(_ kind: Path.Kind) -> Path.Finder { + kinds = kinds ?? [] + kinds!.insert(kind) + return self + } + + /// Multiple calls will configure the Finder with for multiple extensions + func `extension`(_ ext: String) -> Path.Finder { + extensions = extensions ?? [] + extensions!.insert(ext) + 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. + case skip + /// Stop enumerating all together. + case abort + /// Keep going. + case `continue` + } + + /// 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 } + + switch try closure(path) { + case .skip: + finder.skipDescendants() + case .abort: + return + case .continue: + break + } + } + } +} + +public extension Pathish { //MARK: Directory Listings /** - Same as the `ls -a` command ∴ output is ”shallow” and unsorted. - - Parameter includeHiddenFiles: If `true`, hidden files are included in the results. Defaults to `true`. - - Important: `includeHiddenFiles` does not work on Linux + 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. + - Parameter options: Configure the listing. + - Important: On Linux the listing is always `ls -a` */ - func ls(includeHiddenFiles: Bool = true) throws -> [Entry] { - var opts = FileManager.DirectoryEnumerationOptions() - #if !os(Linux) - if !includeHiddenFiles { - opts.insert(.skipsHiddenFiles) + func ls(_ options: ListDirectoryOptions? = nil) -> [Path.Entry] { + guard let urls = try? FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) else { + fputs("warning: could not list: \(self)\n", stderr) + return [] } - #endif - let paths = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: opts) - func convert(url: URL) -> Entry? { + return urls.compactMap { url in guard let path = Path(url.path) else { return nil } - return Entry(kind: path.isDirectory ? .directory : .file, path: path) + if options != .a, path.basename().hasPrefix(".") { return nil } + // ^^ we don’t use the Foundation `skipHiddenFiles` because it considers weird things hidden and we are mirroring `ls` + return .init(kind: path.isDirectory ? .directory : .file, path: path) } - return paths.compactMap(convert) + } + + func find() -> Path.Finder { + return .init(path: Path(self)) } } /// Convenience functions for the array return value of `Path.ls()` -public extension Array where Element == Entry { +public extension Array where Element == Path.Entry { /// Filters the list of entries to be a list of Paths that are directories. var directories: [Path] { return compactMap { @@ -65,3 +151,9 @@ public extension Array where Element == Entry { } } } + +/// Options for `Path.mkdir(_:)` +public enum ListDirectoryOptions { + /// Creates intermediary directories; works the same as `mkdir -p`. + case a +} diff --git a/Tests/PathTests/PathTests+ls().swift b/Tests/PathTests/PathTests+ls().swift new file mode 100644 index 0000000..e7957c7 --- /dev/null +++ b/Tests/PathTests/PathTests+ls().swift @@ -0,0 +1,70 @@ +import XCTest +import Path + +extension PathTests { + func testFindMaxDepth0() throws { + #if !os(Linux) || swift(>=5) + 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))) + } + #endif + } + + func testFindMaxDepth1() throws { + #if !os(Linux) || swift(>=5) + 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 + } + #endif + } + + func testFindExtension() throws { + try Path.mktemp { tmpdir in + try tmpdir.join("foo.json").touch() + try tmpdir.join("bar.txt").touch() + + XCTAssertEqual( + Set(tmpdir.find().extension("json").execute()), + [tmpdir.join("foo.json")]) + XCTAssertEqual( + Set(tmpdir.find().extension("txt").extension("json").execute()), + [tmpdir.join("foo.json"), tmpdir.join("bar.txt")]) + } + } + + func testFindKinds() throws { + try Path.mktemp { tmpdir in + try tmpdir.foo.mkdir() + try tmpdir.bar.touch() + + XCTAssertEqual( + Set(tmpdir.find().kind(.file).execute()), + [tmpdir.join("bar")]) + XCTAssertEqual( + Set(tmpdir.find().kind(.directory).execute()), + [tmpdir.join("foo")]) + XCTAssertEqual( + Set(tmpdir.find().kind(.file).kind(.directory).execute()), + Set(["foo", "bar"].map(tmpdir.join))) + } + } +} diff --git a/Tests/PathTests/XCTestManifests.swift b/Tests/PathTests/XCTestManifests.swift index c3d05c5..e793bb6 100644 --- a/Tests/PathTests/XCTestManifests.swift +++ b/Tests/PathTests/XCTestManifests.swift @@ -23,6 +23,10 @@ extension PathTests { ("testFileHandleExtensions", testFileHandleExtensions), ("testFileReference", testFileReference), ("testFilesystemAttributes", testFilesystemAttributes), + ("testFindExtension", testFindExtension), + ("testFindKinds", testFindKinds), + ("testFindMaxDepth0", testFindMaxDepth0), + ("testFindMaxDepth1", testFindMaxDepth1), ("testFlatMap", testFlatMap), ("testInitializerForRelativePath", testInitializerForRelativePath), ("testIsDirectory", testIsDirectory),