Path.find()

This commit is contained in:
Max Howell
2019-07-21 11:19:34 -04:00
parent 38e98ee7fd
commit d2bb2a1fdc
4 changed files with 230 additions and 31 deletions

View File

@@ -146,7 +146,8 @@ try Bundle.main.resources.join("foo").copy(to: .home)
## Directory listings ## Directory listings
We provide `ls()`, called because it behaves like the Terminal `ls` function, 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 doesnt
list hidden files.
```swift ```swift
for entry in Path.home.ls() { for entry in Path.home.ls() {
@@ -169,6 +170,38 @@ let files = Path.home.ls().files
let swiftFiles = Path.home.ls().files(withExtension: "swift") 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 # `Path.swift` is robust
Some parts of `FileManager` are not exactly idiomatic. For example Some parts of `FileManager` are not exactly idiomatic. For example

View File

@@ -1,49 +1,135 @@
import Foundation import Foundation
/** public extension Path {
A file entry from a directory listing. /**
- SeeAlso: `ls()` A file entry from a directory listing.
*/ - SeeAlso: `ls()`
public struct Entry { */
/// The kind of this directory entry. struct Entry {
public enum Kind { /// The kind of this directory entry.
/// The path is a file. public enum Kind {
case file /// The path is a file.
/// The path is a directory. case file
case directory /// 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<Path.Kind>?
fileprivate(set) public var extensions: Set<String>?
} }
/// 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 //MARK: Directory Listings
/** /**
Same as the `ls -a` command output is shallow and unsorted. Same as the `ls` command output is shallow and unsorted.
- Parameter includeHiddenFiles: If `true`, hidden files are included in the results. Defaults to `true`. - Note: as per `ls`, by default we do *not* return hidden files. Specify `.a` for hidden files.
- Important: `includeHiddenFiles` does not work on Linux - Parameter options: Configure the listing.
- Important: On Linux the listing is always `ls -a`
*/ */
func ls(includeHiddenFiles: Bool = true) throws -> [Entry] { func ls(_ options: ListDirectoryOptions? = nil) -> [Path.Entry] {
var opts = FileManager.DirectoryEnumerationOptions() guard let urls = try? FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) else {
#if !os(Linux) fputs("warning: could not list: \(self)\n", stderr)
if !includeHiddenFiles { return []
opts.insert(.skipsHiddenFiles)
} }
#endif return urls.compactMap { url in
let paths = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: opts)
func convert(url: URL) -> Entry? {
guard let path = Path(url.path) else { return nil } 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 dont 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()` /// 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. /// Filters the list of entries to be a list of Paths that are directories.
var directories: [Path] { var directories: [Path] {
return compactMap { 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
}

View File

@@ -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)))
}
}
}

View File

@@ -23,6 +23,10 @@ extension PathTests {
("testFileHandleExtensions", testFileHandleExtensions), ("testFileHandleExtensions", testFileHandleExtensions),
("testFileReference", testFileReference), ("testFileReference", testFileReference),
("testFilesystemAttributes", testFilesystemAttributes), ("testFilesystemAttributes", testFilesystemAttributes),
("testFindExtension", testFindExtension),
("testFindKinds", testFindKinds),
("testFindMaxDepth0", testFindMaxDepth0),
("testFindMaxDepth1", testFindMaxDepth1),
("testFlatMap", testFlatMap), ("testFlatMap", testFlatMap),
("testInitializerForRelativePath", testInitializerForRelativePath), ("testInitializerForRelativePath", testInitializerForRelativePath),
("testIsDirectory", testIsDirectory), ("testIsDirectory", testIsDirectory),