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
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
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

View File

@@ -1,10 +1,11 @@
import Foundation
/**
public extension Path {
/**
A file entry from a directory listing.
- SeeAlso: `ls()`
*/
public struct Entry {
*/
struct Entry {
/// The kind of this directory entry.
public enum Kind {
/// The path is a file.
@@ -16,34 +17,119 @@ public struct 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>?
}
}
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 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()`
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
}

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),
("testFileReference", testFileReference),
("testFilesystemAttributes", testFilesystemAttributes),
("testFindExtension", testFindExtension),
("testFindKinds", testFindKinds),
("testFindMaxDepth0", testFindMaxDepth0),
("testFindMaxDepth1", testFindMaxDepth1),
("testFlatMap", testFlatMap),
("testInitializerForRelativePath", testInitializerForRelativePath),
("testIsDirectory", testIsDirectory),