Compare commits

..

8 Commits

Author SHA1 Message Date
Max Howell
af091cc1f0 Split this test so I can figure out CI failure 2019-07-21 18:58:50 -04:00
Max Howell
e5188bf93b Still not fixed 2019-07-21 18:58:49 -04:00
Max Howell
462a62920f Update Swifts in CI; Test Xcode 11 2019-07-21 18:58:49 -04:00
Max Howell
45b0b59a94 Better rx for tagged version detection on Travis 2019-07-21 17:37:10 -04:00
Max Howell
62073d584b Remove Entry since it is barely worthwhile sugar 2019-07-21 17:37:10 -04:00
Max Howell
621d1b0160 Remove @dynamicMember generally 2019-07-21 17:37:10 -04:00
Max Howell
d2bb2a1fdc Path.find() 2019-07-21 17:22:38 -04:00
Max Howell
38e98ee7fd Jazzy requires Xcode 10.2 now (per their docs) 2019-07-21 09:07:03 -04:00
17 changed files with 454 additions and 202 deletions

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@
/*.xcodeproj
/build
/docs
/.swiftpm

View File

@@ -1,11 +1,11 @@
# only run for: merge commits, releases and pull-requests
if: type != push OR branch = master OR branch =~ /^\d+\.\d+(\.\d+)?(-\S*)?$/
if: type != push OR branch = master OR branch =~ /^\d+\.\d+\.\d+(-.*)?$/
stages:
- name: pretest
- name: test
- name: deploy
if: branch =~ ^\d+\.\d+\.\d+$
if: branch =~ /^\d+\.\d+\.\d+(-.*)?$/
os: osx
language: swift
@@ -25,6 +25,10 @@ jobs:
osx_image: xcode10.2
script: swift test --parallel
- name: macOS / Swift 5.1
osx_image: xcode11
script: swift test --parallel
- &xcodebuild
before_install: swift package generate-xcodeproj --enable-code-coverage
xcode_destination: platform=iOS Simulator,OS=latest,name=iPhone XS
@@ -47,18 +51,21 @@ jobs:
after_success: false
- &linux
env: SWIFT_VERSION=4.2.1
env: SWIFT_VERSION=4.2.4
os: linux
name: Linux / Swift 4.2.1
name: Linux / Swift 4.2.4
language: generic
dist: trusty
sudo: false
install: eval "$(curl -sL https://swiftenv.fuller.li/install.sh)"
script: swift test --parallel
- <<: *linux
env: SWIFT_VERSION='5.0'
name: Linux / Swift 5.0.0
env: SWIFT_VERSION='5.0.2'
name: Linux / Swift 5.0.2
- <<: *linux
env: SWIFT_VERSION=5.1-DEVELOPMENT-SNAPSHOT-2019-07-03-a
name: Linux / Swift 5.1 (2019-07-03)
- stage: pretest
name: Check Linux tests are syncd
@@ -68,6 +75,7 @@ jobs:
- stage: deploy
name: Jazzy
osx_image: xcode10.2
install: gem install jazzy
before_script: swift package generate-xcodeproj
script: |

View File

@@ -32,7 +32,7 @@ let foo = try Path.root.join("foo").copy(into: Path.root.join("bar").mkdir())
print(foo) // => /bar/foo
print(foo.isFile) // => true
// we support dynamic members (_use_sparingly_):
// we support dynamic-member-syntax when joining named static members, eg:
let prefs = Path.home.Library.Preferences // => /Users/mxcl/Library/Preferences
// a practical example: installing a helper executable
@@ -107,10 +107,11 @@ We support `@dynamicMemberLookup`:
let ls = Path.root.usr.bin.ls // => /usr/bin/ls
```
This is less commonly useful than you would think, hence our documentation
does not use it. Usually you are joining variables or other `String` arguments
or trying to describe files (and files usually have extensions). However when
you need it, its *lovely*.
We only provide this for “starting” function, eg. `Path.home` or `Bundle.path`.
This is because we found in practice it was easy to write incorrect code, since
everything would compile if we allowed arbituary variables to take *any* named
property as valid syntax. What we have is what you want most of the time but
much less dangerous.
## Initializing from user-input
@@ -146,27 +147,61 @@ 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() {
print(entry.path)
print(entry.kind) // .directory or .file
}
for entry in Path.home.ls() where entry.kind == .file {
for path in Path.home.ls() {
//
}
for entry in Path.home.ls() where entry.path.mtime > yesterday {
for path in Path.home.ls() where path.isFile {
//
}
for path in Path.home.ls() where path.mtime > yesterday {
//
}
let dirs = Path.home.ls().directories
// ^^ directories that *exist*
let files = Path.home.ls().files
// ^^ files that both *exist* and are *not* directories
let swiftFiles = Path.home.ls().files(withExtension: "swift")
let swiftFiles = Path.home.ls().files.filter{ $0.extension == "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

View File

@@ -13,31 +13,31 @@ public extension Bundle {
Returns the path for the shared-frameworks directory in this bundle.
- Note: This is typically `ShareFrameworks`
*/
var sharedFrameworks: Path {
return sharedFrameworksPath.flatMap(Path.init) ?? defaultSharedFrameworksPath
var sharedFrameworks: DynamicPath {
return sharedFrameworksPath.flatMap(DynamicPath.init) ?? defaultSharedFrameworksPath
}
/**
Returns the path for the private-frameworks directory in this bundle.
- Note: This is typically `Frameworks`
*/
var privateFrameworks: Path {
return privateFrameworksPath.flatMap(Path.init) ?? defaultSharedFrameworksPath
var privateFrameworks: DynamicPath {
return privateFrameworksPath.flatMap(DynamicPath.init) ?? defaultSharedFrameworksPath
}
/// Returns the path for the resources directory in this bundle.
var resources: Path {
return resourcePath.flatMap(Path.init) ?? defaultResourcesPath
var resources: DynamicPath {
return resourcePath.flatMap(DynamicPath.init) ?? defaultResourcesPath
}
/// Returns the path for this bundle.
var path: Path {
return Path(string: bundlePath)
var path: DynamicPath {
return DynamicPath(string: bundlePath)
}
/// Returns the executable for this bundle, if there is one, not all bundles have one hence `Optional`.
var executable: Path? {
return executablePath.flatMap(Path.init)
var executable: DynamicPath? {
return executablePath.flatMap(DynamicPath.init)
}
}
@@ -45,14 +45,14 @@ public extension Bundle {
public extension String {
/// Initializes this `String` with the contents of the provided path.
@inlinable
init(contentsOf path: Path) throws {
init<P: Pathish>(contentsOf path: P) throws {
try self.init(contentsOfFile: path.string)
}
/// - Returns: `to` to allow chaining
@inlinable
@discardableResult
func write(to: Path, atomically: Bool = false, encoding: String.Encoding = .utf8) throws -> Path {
func write<P: Pathish>(to: P, atomically: Bool = false, encoding: String.Encoding = .utf8) throws -> P {
try write(toFile: to.string, atomically: atomically, encoding: encoding)
return to
}
@@ -62,14 +62,14 @@ public extension String {
public extension Data {
/// Initializes this `Data` with the contents of the provided path.
@inlinable
init(contentsOf path: Path) throws {
init<P: Pathish>(contentsOf path: P) throws {
try self.init(contentsOf: path.url)
}
/// - Returns: `to` to allow chaining
@inlinable
@discardableResult
func write(to: Path, atomically: Bool = false) throws -> Path {
func write<P: Pathish>(to: P, atomically: Bool = false) throws -> P {
let opts: NSData.WritingOptions
if atomically {
#if !os(Linux)
@@ -89,39 +89,39 @@ public extension Data {
public extension FileHandle {
/// Initializes this `FileHandle` for reading at the location of the provided path.
@inlinable
convenience init(forReadingAt path: Path) throws {
convenience init<P: Pathish>(forReadingAt path: P) throws {
try self.init(forReadingFrom: path.url)
}
/// Initializes this `FileHandle` for writing at the location of the provided path.
@inlinable
convenience init(forWritingAt path: Path) throws {
convenience init<P: Pathish>(forWritingAt path: P) throws {
try self.init(forWritingTo: path.url)
}
/// Initializes this `FileHandle` for reading and writing at the location of the provided path.
@inlinable
convenience init(forUpdatingAt path: Path) throws {
convenience init<P: Pathish>(forUpdatingAt path: P) throws {
try self.init(forUpdating: path.url)
}
}
internal extension Bundle {
var defaultSharedFrameworksPath: Path {
var defaultSharedFrameworksPath: DynamicPath {
#if os(macOS)
return path.join("Contents/Frameworks")
return path.Contents.Frameworks
#elseif os(Linux)
return path.join("lib")
return path.lib
#else
return path.join("Frameworks")
return path.Frameworks
#endif
}
var defaultResourcesPath: Path {
var defaultResourcesPath: DynamicPath {
#if os(macOS)
return path.join("Contents/Resources")
return path.Contents.Resources
#elseif os(Linux)
return path.join("share")
return path.share
#else
return path
#endif

View File

@@ -1,6 +1,6 @@
import Foundation
public extension Path {
public extension Pathish {
//MARK: Filesystem Attributes
/**
@@ -38,7 +38,7 @@ public extension Path {
@discardableResult
func chmod(_ octal: Int) throws -> Path {
try FileManager.default.setAttributes([.posixPermissions: octal], ofItemAtPath: string)
return self
return Path(self)
}
/**
@@ -57,7 +57,7 @@ public extension Path {
try FileManager.default.setAttributes(attrs, ofItemAtPath: string)
}
#endif
return self
return Path(self)
}
/**
@@ -73,7 +73,7 @@ public extension Path {
do {
attrs = try FileManager.default.attributesOfItem(atPath: string)
} catch CocoaError.fileReadNoSuchFile {
return self
return Path(self)
}
let b = attrs[.immutable] as? Bool ?? false
if b {
@@ -81,14 +81,10 @@ public extension Path {
try FileManager.default.setAttributes(attrs, ofItemAtPath: string)
}
#endif
return self
return Path(self)
}
enum Kind {
case file, symlink, directory
}
var kind: Kind? {
var kind: Path.Kind? {
var buf = stat()
guard lstat(string, &buf) == 0 else {
return nil
@@ -102,3 +98,9 @@ public extension Path {
}
}
}
public extension Path {
enum Kind {
case file, symlink, directory
}
}

View File

@@ -23,18 +23,19 @@ public extension CodingUserInfoKey {
Provided for relative-path coding. See the instructions in our
[README](https://github.com/mxcl/Path.swift/#codable).
*/
extension Path: Codable {
extension Path: Codable {
/// - SeeAlso: `CodingUserInfoKey.relativePath`
/// :nodoc:
public init(from decoder: Decoder) throws {
let value = try decoder.singleValueContainer().decode(String.self)
if value.hasPrefix("/") {
string = value
} else {
guard let root = decoder.userInfo[.relativePath] as? Path else {
throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Path cannot decode a relative path if `userInfo[.relativePath]` not set to a Path object."))
}
} else if let root = decoder.userInfo[.relativePath] as? Path {
string = (root/value).string
} else if let root = decoder.userInfo[.relativePath] as? DynamicPath {
string = (root/value).string
} else {
throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Path cannot decode a relative path if `userInfo[.relativePath]` not set to a Path object."))
}
}
@@ -44,6 +45,8 @@ extension Path: Codable {
var container = encoder.singleValueContainer()
if let root = encoder.userInfo[.relativePath] as? Path {
try container.encode(relative(to: root))
} else if let root = encoder.userInfo[.relativePath] as? DynamicPath {
try container.encode(relative(to: root))
} else {
try container.encode(string)
}

View File

@@ -4,17 +4,17 @@ extension Path {
//MARK: Common Directories
/// Returns a `Path` containing `FileManager.default.currentDirectoryPath`.
public static var cwd: Path {
return Path(string: FileManager.default.currentDirectoryPath)
public static var cwd: DynamicPath {
return .init(string: FileManager.default.currentDirectoryPath)
}
/// Returns a `Path` representing the root path.
public static var root: Path {
return Path(string: "/")
public static var root: DynamicPath {
return .init(string: "/")
}
/// Returns a `Path` representing the users home directory
public static var home: Path {
public static var home: DynamicPath {
let string: String
#if os(macOS)
if #available(OSX 10.12, *) {
@@ -25,30 +25,30 @@ extension Path {
#else
string = NSHomeDirectory()
#endif
return Path(string: string)
return .init(string: string)
}
/// Helper to allow search path and domain mask to be passed in.
private static func path(for searchPath: FileManager.SearchPathDirectory) -> Path {
private static func path(for searchPath: FileManager.SearchPathDirectory) -> DynamicPath {
#if os(Linux)
// the urls(for:in:) function is not implemented on Linux
//TODO strictly we should first try to use the provided binary tool
let foo = { ProcessInfo.processInfo.environment[$0].flatMap(Path.init) ?? $1 }
let foo = { ProcessInfo.processInfo.environment[$0].flatMap(Path.init).map(DynamicPath.init) ?? $1 }
switch searchPath {
case .documentDirectory:
return Path.home/"Documents"
return Path.home.Documents
case .applicationSupportDirectory:
return foo("XDG_DATA_HOME", Path.home/".local/share")
return foo("XDG_DATA_HOME", Path.home[dynamicMember: ".local/share"])
case .cachesDirectory:
return foo("XDG_CACHE_HOME", Path.home/".cache")
return foo("XDG_CACHE_HOME", Path.home[dynamicMember: ".cache"])
default:
fatalError()
}
#else
guard let pathString = FileManager.default.urls(for: searchPath, in: .userDomainMask).first?.path else { return defaultUrl(for: searchPath) }
return Path(string: pathString)
return DynamicPath(string: pathString)
#endif
}
@@ -57,7 +57,7 @@ extension Path {
- Note: There is no standard location for documents on Linux, thus we return `~/Documents`.
- Note: You should create a subdirectory before creating any files.
*/
public static var documents: Path {
public static var documents: DynamicPath {
return path(for: .documentDirectory)
}
@@ -66,7 +66,7 @@ extension Path {
- Note: On Linux this is `XDG_CACHE_HOME`.
- Note: You should create a subdirectory before creating any files.
*/
public static var caches: Path {
public static var caches: DynamicPath {
return path(for: .cachesDirectory)
}
@@ -75,20 +75,20 @@ extension Path {
- Note: On Linux is `XDG_DATA_HOME`.
- Note: You should create a subdirectory before creating any files.
*/
public static var applicationSupport: Path {
public static var applicationSupport: DynamicPath {
return path(for: .applicationSupportDirectory)
}
}
#if !os(Linux)
func defaultUrl(for searchPath: FileManager.SearchPathDirectory) -> Path {
func defaultUrl(for searchPath: FileManager.SearchPathDirectory) -> DynamicPath {
switch searchPath {
case .documentDirectory:
return Path.home/"Documents"
return Path.home.Documents
case .applicationSupportDirectory:
return Path.home/"Library/Application Support"
return Path.home.Library[dynamicMember: "Application Support"]
case .cachesDirectory:
return Path.home/"Library/Caches"
return Path.home.Library.Caches
default:
fatalError()
}

View File

@@ -3,7 +3,7 @@ import Foundation
import Glibc
#endif
public extension Path {
public extension Pathish {
//MARK: File Management
/**
@@ -24,17 +24,17 @@ public extension Path {
- SeeAlso: `copy(into:overwrite:)`
*/
@discardableResult
func copy(to: Path, 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 {
try FileManager.default.removeItem(at: to.url)
}
#if os(Linux) && !swift(>=5.1) // check if fixed
#if os(Linux) && !swift(>=5.2) // check if fixed
if !overwrite, to.kind != nil {
throw CocoaError.error(.fileWriteFileExists)
}
#endif
try FileManager.default.copyItem(atPath: string, toPath: to.string)
return to
return Path(to)
}
/**
@@ -60,7 +60,7 @@ public extension Path {
- SeeAlso: `copy(to:overwrite:)`
*/
@discardableResult
func copy(into: Path, overwrite: Bool = false) throws -> Path {
func copy<P: Pathish>(into: P, overwrite: Bool = false) throws -> Path {
if into.kind == nil {
try into.mkdir(.p)
}
@@ -68,7 +68,7 @@ public extension Path {
if overwrite, let kind = rv.kind, kind != .directory {
try FileManager.default.removeItem(at: rv.url)
}
#if os(Linux) && !swift(>=5.1) // check if fixed
#if os(Linux) && !swift(>=5.2) // check if fixed
if !overwrite, rv.kind != nil {
throw CocoaError.error(.fileWriteFileExists)
}
@@ -94,12 +94,12 @@ public extension Path {
- SeeAlso: `move(into:overwrite:)`
*/
@discardableResult
func move(to: Path, overwrite: Bool = false) throws -> Path {
func move<P: Pathish>(to: P, overwrite: Bool = false) throws -> Path {
if overwrite, let kind = to.kind, kind != .directory {
try FileManager.default.removeItem(at: to.url)
}
try FileManager.default.moveItem(at: url, to: to.url)
return to
return Path(to)
}
/**
@@ -118,7 +118,7 @@ public extension Path {
- SeeAlso: `move(to:overwrite:)`
*/
@discardableResult
func move(into: Path, overwrite: Bool = false) throws -> Path {
func move<P: Pathish>(into: P, overwrite: Bool = false) throws -> Path {
switch into.kind {
case nil:
try into.mkdir(.p)
@@ -154,7 +154,7 @@ public extension Path {
/**
Creates an empty file at this path or if the file exists, updates its modification time.
- Returns: `self` to allow chaining.
- Returns: A copy of `self` to allow chaining.
*/
@inlinable
@discardableResult
@@ -172,7 +172,7 @@ public extension Path {
try FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: string)
#endif
}
return self
return Path(self)
}
/**
@@ -180,7 +180,7 @@ public extension Path {
- Parameter options: Specify `mkdir(.p)` to create intermediary directories.
- Note: We do not error if the directory already exists (even without `.p`)
because *Path.swift* noops if the desired end result preexists.
- Returns: `self` to allow chaining.
- Returns: A copy of `self` to allow chaining.
*/
@discardableResult
func mkdir(_ options: MakeDirectoryOptions? = nil) throws -> Path {
@@ -199,7 +199,7 @@ public extension Path {
throw error
#endif
}
return self
return Path(self)
}
/**
@@ -222,9 +222,9 @@ public extension Path {
- Note: If `self` does not exist, is **not** an error.
*/
@discardableResult
func symlink(as: Path) throws -> Path {
func symlink<P: Pathish>(as: P) throws -> Path {
try FileManager.default.createSymbolicLink(atPath: `as`.string, withDestinationPath: string)
return `as`
return Path(`as`)
}
/**
@@ -232,7 +232,7 @@ public extension Path {
- Note: If into does not exist, creates the directory with intermediate directories if necessary.
*/
@discardableResult
func symlink(into dir: Path) throws -> Path {
func symlink<P: Pathish>(into dir: P) throws -> Path {
switch dir.kind {
case nil, .symlink?:
try dir.mkdir(.p)

View File

@@ -11,3 +11,17 @@ extension Path: CustomDebugStringConvertible {
return "Path(\(string))"
}
}
extension DynamicPath: CustomStringConvertible {
/// Returns `Path.string`
public var description: String {
return string
}
}
extension DynamicPath: CustomDebugStringConvertible {
/// Returns eg. `Path(string: "/foo")`
public var debugDescription: String {
return "Path(\(string))"
}
}

View File

@@ -1,67 +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 {
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
/**
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] {
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 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 {
/// Filters the list of entries to be a list of Paths that are directories.
/// Convenience functions for the arraies 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] {
return compactMap {
$0.kind == .directory ? $0.path : nil
return filter {
$0.isDirectory
}
}
/// Filters the list of entries to be a list of Paths that are files.
/// Filters the list of entries to be a list of Paths that exist and are *not* directories. Thus expect symlinks, etc.
/// - Note: symlinks that point to files that do not exist are *not* returned.
var files: [Path] {
return compactMap {
$0.kind == .file ? $0.path : nil
}
}
/// Filters the list of entries to be a list of Paths that are files with the specified extension.
func files(withExtension ext: String) -> [Path] {
return compactMap {
$0.kind == .file && $0.path.extension == ext ? $0.path : nil
return filter {
$0.exists && !$0.isDirectory
}
}
}
/// Options for `Path.mkdir(_:)`
public enum ListDirectoryOptions {
/// Creates intermediary directories; works the same as `mkdir -p`.
case a
}

View File

@@ -5,7 +5,7 @@ import func Glibc.access
import Darwin
#endif
public extension Path {
public extension Pathish {
//MARK: Filesystem Properties
/**

View File

@@ -32,11 +32,15 @@ let _realpath = Glibc.realpath
let p1 = Path.root.usr.bin.ls // => /usr/bin/ls
However we only provide this support off of the static members like `root` due
to the anti-pattern where Path.swift suddenly feels like Javascript otherwise.
- Note: A `Path` does not necessarily represent an actual filesystem entry.
*/
public struct Path: Pathish {
@dynamicMemberLookup
public struct Path: Equatable, Hashable, Comparable {
/// The normalized string representation of the underlying filesystem path
public let string: String
init(string: String) {
assert(string.first == "/")
@@ -70,11 +74,11 @@ public struct Path: Equatable, Hashable, Comparable {
ifExists(withPrefix: "/var/automount", removeFirst: 2)
ifExists(withPrefix: "/private", removeFirst: 1)
#endif
self.string = join_(prefix: "/", pathComponents: pathComponents)
string = join_(prefix: "/", pathComponents: pathComponents)
case "~":
if description == "~" {
self = Path.home
string = Path.home.string
return
}
let tilded: String
@@ -96,7 +100,7 @@ public struct Path: Equatable, Hashable, Comparable {
#endif
}
pathComponents.remove(at: 0)
self.string = join_(prefix: tilded, pathComponents: pathComponents)
string = join_(prefix: tilded, pathComponents: pathComponents)
default:
return nil
@@ -124,20 +128,15 @@ public struct Path: Equatable, Hashable, Comparable {
// ^^ works even if the url is a file-reference url
}
/// :nodoc:
public subscript(dynamicMember addendum: String) -> Path {
//NOTE its possible for the string to be anything if we are invoked via
// explicit subscript thus we use our fully sanitized `join` function
return Path(string: join_(prefix: string, appending: addendum))
/// Converts anything that is `Pathish` to a `Path`
public init<P: Pathish>(_ path: P) {
string = path.string
}
}
//MARK: Properties
/// The underlying filesystem path
public let string: String
public extension Pathish {
/// Returns a `URL` representing this file path.
public var url: URL {
var url: URL {
return URL(fileURLWithPath: string)
}
@@ -147,7 +146,7 @@ public struct Path: Equatable, Hashable, Comparable {
- SeeAlso: https://developer.apple.com/documentation/foundation/nsurl/1408631-filereferenceurl
- Important: On Linux returns an file scheme NSURL for this path string.
*/
public var fileReferenceURL: NSURL? {
var fileReferenceURL: NSURL? {
#if !os(Linux)
// https://bugs.swift.org/browse/SR-2728
return (url as NSURL).perform(#selector(NSURL.fileReferenceURL))?.takeUnretainedValue() as? NSURL
@@ -164,7 +163,7 @@ public struct Path: Equatable, Hashable, Comparable {
- Note: always returns a valid path, `Path.root.parent` *is* `Path.root`.
*/
public var parent: Path {
var parent: Path {
let index = string.lastIndex(of: "/")!
let substr = string[string.indices.startIndex..<index]
return Path(string: String(substr))
@@ -177,7 +176,7 @@ public struct Path: Equatable, Hashable, Comparable {
- Note: We special case eg. `foo.tar.gz`.
*/
@inlinable
public var `extension`: String {
var `extension`: String {
//FIXME efficiency
switch true {
case string.hasSuffix(".tar.gz"):
@@ -204,7 +203,7 @@ public struct Path: Equatable, Hashable, Comparable {
- Important: The first element is always "/" to be consistent with `NSString.pathComponents`.
*/
@inlinable
public var components: [String] {
var components: [String] {
return ["/"] + string.split(separator: "/").map(String.init)
}
@@ -225,7 +224,7 @@ public struct Path: Equatable, Hashable, Comparable {
- Returns: A new joined path.
- SeeAlso: `Path./(_:_:)`
*/
public func join<S>(_ addendum: S) -> Path where S: StringProtocol {
func join<S>(_ addendum: S) -> Path where S: StringProtocol {
return Path(string: join_(prefix: string, appending: addendum))
}
@@ -246,7 +245,7 @@ public struct Path: Equatable, Hashable, Comparable {
- SeeAlso: `join(_:)`
*/
@inlinable
public static func /<S>(lhs: Path, rhs: S) -> Path where S: StringProtocol {
static func /<S>(lhs: Self, rhs: S) -> Path where S: StringProtocol {
return lhs.join(rhs)
}
@@ -257,7 +256,7 @@ public struct Path: Equatable, Hashable, Comparable {
- Parameter base: The base to which we calculate the relative path.
- ToDo: Another variant that returns `nil` if result would start with `..`
*/
public func relative(to base: Path) -> String {
func relative<P: Pathish>(to base: P) -> String {
// Split the two paths into their components.
// FIXME: The is needs to be optimized to avoid unncessary copying.
let pathComps = (string as NSString).pathComponents
@@ -296,7 +295,7 @@ public struct Path: Equatable, Hashable, Comparable {
- Returns: A string that is the filenames basename.
- Parameter dropExtension: If `true` returns the basename without its file extension.
*/
public func basename(dropExtension: Bool = false) -> String {
func basename(dropExtension: Bool = false) -> String {
var lastPathComponent: Substring {
let slash = string.lastIndex(of: "/")!
let index = string.index(after: slash)
@@ -321,27 +320,25 @@ public struct Path: Equatable, Hashable, Comparable {
If the path represents an actual entry that is a symlink, returns the symlinks
absolute destination.
- Important: This is not exhaustive, the resulting path may still contain
symlink.
- Important: The path will only be different if the last path component is a
symlink, any symlinks in prior components are not resolved.
- Important: This is not exhaustive, the resulting path may still contain a symlink.
- Important: The path will only be different if the last path component is a symlink, any symlinks in prior components are not resolved.
- Note: If file exists but isnt a symlink, returns `self`.
- Note: If symlink destination does not exist, is **not** an error.
*/
public func readlink() throws -> Path {
func readlink() throws -> Path {
do {
let rv = try FileManager.default.destinationOfSymbolicLink(atPath: string)
return Path(rv) ?? parent/rv
} catch CocoaError.fileReadUnknown {
// file is not symlink, return `self`
assert(exists)
return self
return Path(string: string)
} catch {
#if os(Linux)
// ugh: Swift on Linux
let nsError = error as NSError
if nsError.domain == NSCocoaErrorDomain, nsError.code == CocoaError.fileReadUnknown.rawValue, exists {
return self
return Path(self)
}
#endif
throw error
@@ -349,7 +346,7 @@ public struct Path: Equatable, Hashable, Comparable {
}
/// Recursively resolves symlinks in this path.
public func realpath() throws -> Path {
func realpath() throws -> Path {
guard let rv = _realpath(string, nil) else { throw CocoaError.error(.fileNoSuchFile) }
defer { free(rv) }
guard let rvv = String(validatingUTF8: rv) else { throw CocoaError.error(.fileReadUnknownStringEncoding) }
@@ -367,7 +364,7 @@ public struct Path: Equatable, Hashable, Comparable {
/// Returns the locale-aware sort order for the two paths.
/// :nodoc:
@inlinable
public static func <(lhs: Path, rhs: Path) -> Bool {
static func <(lhs: Self, rhs: Self) -> Bool {
return lhs.string.compare(rhs.string, locale: .current) == .orderedAscending
}
}
@@ -406,3 +403,27 @@ private func join_<S>(prefix: String, pathComponents: S) -> String where S: Sequ
}
return rv
}
/// 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
public let string: String
init(string: String) {
assert(string.hasPrefix("/"))
self.string = string
}
/// Converts a `Path` to a `DynamicPath`
public init(_ path: Path) {
string = path.string
}
/// :nodoc:
public subscript(dynamicMember addendum: String) -> DynamicPath {
//NOTE its possible for the string to be anything if we are invoked via
// explicit subscript thus we use our fully sanitized `join` function
return DynamicPath(string: join_(prefix: string, appending: addendum))
}
}

13
Sources/Pathish.swift Normal file
View File

@@ -0,0 +1,13 @@
/// 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 {
static func ==<P: Pathish> (lhs: Self, rhs: P) -> Bool {
return lhs.string == rhs.string
}
}

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

@@ -1,4 +1,6 @@
@testable import Path
import func XCTest.XCTAssertEqual
import Foundation
import XCTest
class PathTests: XCTestCase {
@@ -18,32 +20,31 @@ class PathTests: XCTestCase {
func testEnumeration() throws {
let tmpdir_ = try TemporaryDirectory()
let tmpdir = tmpdir_.path
try tmpdir.a.mkdir().c.touch()
try tmpdir.join("a").mkdir().join("c").touch()
try tmpdir.join("b.swift").touch()
try tmpdir.c.touch()
try tmpdir.join(".d").mkdir().e.touch()
try tmpdir.join("c").touch()
try tmpdir.join(".d").mkdir().join("e").touch()
var paths = Set<String>()
let lsrv = try tmpdir.ls()
let lsrv = tmpdir.ls(.a)
var dirs = 0
for entry in lsrv {
if entry.kind == .directory {
for path in lsrv {
if path.isDirectory {
dirs += 1
}
paths.insert(entry.path.basename())
paths.insert(path.basename())
}
XCTAssertEqual(dirs, 2)
XCTAssertEqual(dirs, lsrv.directories.count)
XCTAssertEqual(["a", ".d"], Set(lsrv.directories.map{ $0.relative(to: tmpdir) }))
XCTAssertEqual(["b.swift", "c"], Set(lsrv.files.map{ $0.relative(to: tmpdir) }))
XCTAssertEqual(["b.swift"], Set(lsrv.files(withExtension: "swift").map{ $0.relative(to: tmpdir) }))
XCTAssertEqual(["c"], Set(lsrv.files(withExtension: "").map{ $0.relative(to: tmpdir) }))
XCTAssertEqual(["b.swift"], Set(lsrv.files.filter{ $0.extension == "swift" }.map{ $0.relative(to: tmpdir) }))
XCTAssertEqual(["c"], Set(lsrv.files.filter{ $0.extension == "" }.map{ $0.relative(to: tmpdir) }))
XCTAssertEqual(paths, ["a", "b.swift", "c", ".d"])
}
func testEnumerationSkippingHiddenFiles() throws {
#if !os(Linux)
let tmpdir_ = try TemporaryDirectory()
let tmpdir = tmpdir_.path
try tmpdir.join("a").mkdir().join("c").touch()
@@ -53,20 +54,19 @@ class PathTests: XCTestCase {
var paths = Set<String>()
var dirs = 0
for entry in try tmpdir.ls(includeHiddenFiles: false) {
if entry.kind == .directory {
for path in tmpdir.ls() {
if path.isDirectory {
dirs += 1
}
paths.insert(entry.path.basename())
paths.insert(path.basename())
}
XCTAssertEqual(dirs, 1)
XCTAssertEqual(paths, ["a", "b", "c"])
#endif
}
func testRelativeTo() {
XCTAssertEqual((Path.root/"tmp/foo").relative(to: .root/"tmp"), "foo")
XCTAssertEqual((Path.root/"tmp/foo/bar").relative(to: .root/"tmp/baz"), "../foo/bar")
XCTAssertEqual((Path.root.tmp.foo).relative(to: Path.root/"tmp"), "foo")
XCTAssertEqual((Path.root.tmp.foo.bar).relative(to: Path.root/"tmp/baz"), "../foo/bar")
}
func testExists() throws {
@@ -105,7 +105,7 @@ class PathTests: XCTestCase {
}
func testMktemp() throws {
var path: Path!
var path: DynamicPath!
try Path.mktemp {
path = $0
XCTAssert(path.isDirectory)
@@ -133,7 +133,7 @@ class PathTests: XCTestCase {
}
func testCodable() throws {
let input = [Path.root.foo, Path.root.foo.bar, Path.root]
let input = [Path.root.foo, Path.root.foo.bar, Path.root].map(Path.init)
XCTAssertEqual(try JSONDecoder().decode([Path].self, from: try JSONEncoder().encode(input)), input)
}
@@ -143,7 +143,7 @@ class PathTests: XCTestCase {
Path.root,
root,
root.bar
]
].map(Path.init)
let encoder = JSONEncoder()
encoder.userInfo[.relativePath] = root
@@ -204,7 +204,9 @@ class PathTests: XCTestCase {
XCTAssertThrowsError(try root.foo.copy(to: root.bar))
try root.foo.copy(to: root.bar, overwrite: true)
}
}
func testCopyToExistingDirectoryFails() throws {
// test copy errors if directory exists at destination, even with overwrite
try Path.mktemp { root in
try root.foo.touch()
@@ -407,7 +409,7 @@ class PathTests: XCTestCase {
}
func testRelativeCodable() throws {
let path = Path.home.foo
let path = Path(Path.home.foo)
let encoder = JSONEncoder()
encoder.userInfo[.relativePath] = Path.home
let data = try encoder.encode([path])
@@ -415,13 +417,15 @@ class PathTests: XCTestCase {
decoder.userInfo[.relativePath] = Path.home
XCTAssertEqual(try decoder.decode([Path].self, from: data), [path])
decoder.userInfo[.relativePath] = Path.documents
XCTAssertEqual(try decoder.decode([Path].self, from: data), [Path.documents.foo])
XCTAssertEqual(try decoder.decode([Path].self, from: data), [Path(Path.documents.foo)])
XCTAssertThrowsError(try JSONDecoder().decode([Path].self, from: data))
}
func testBundleExtensions() throws {
try Path.mktemp { tmpdir in
let bndl = Bundle(path: tmpdir.string)!
try Path.mktemp { tmpdir -> Void in
guard let bndl = Bundle(path: tmpdir.string) else {
return XCTFail("Couldnt make Bundle for \(tmpdir)")
}
XCTAssertEqual(bndl.path, tmpdir)
XCTAssertEqual(bndl.sharedFrameworks, tmpdir.SharedFrameworks)
XCTAssertEqual(bndl.privateFrameworks, tmpdir.Frameworks)
@@ -513,7 +517,7 @@ class PathTests: XCTestCase {
let foo = try tmpdir.foo.touch()
let bar = try tmpdir.bar.mkdir()
XCTAssertThrowsError(try foo.symlink(as: bar))
XCTAssert(try foo.symlink(as: bar.foo).isSymlink)
XCTAssert(try foo.symlink(as: bar/"foo").isSymlink)
}
}
@@ -568,8 +572,8 @@ class PathTests: XCTestCase {
try Path.mktemp { tmpdir in
let foo = try tmpdir.foo.mkdir()
try foo.bar.mkdir().fuz.touch()
let baz = try foo.symlink(as: tmpdir.baz)
try foo.join("bar").mkdir().join("fuz").touch()
let baz = DynamicPath(try foo.symlink(as: tmpdir.baz))
XCTAssert(baz.isSymlink)
XCTAssert(baz.bar.isDirectory)
XCTAssertEqual(baz.bar.join("..").string, "\(tmpdir)/baz")
@@ -582,7 +586,7 @@ class PathTests: XCTestCase {
try Path.mktemp { tmpdir in
let b = try tmpdir.a.b.mkdir(.p)
let c = try tmpdir.a.c.touch()
let e = try c.symlink(as: b.e)
let e = try c.symlink(as: b/"e")
let f = try e.symlink(as: tmpdir.f)
XCTAssertEqual(try f.readlink(), e)
XCTAssertEqual(try f.realpath(), c)
@@ -639,3 +643,11 @@ class PathTests: XCTestCase {
}
}
}
private func XCTAssertEqual<P: Pathish, Q: Pathish>(_ p: P, _ q: Q, file: StaticString = #file, line: UInt = #line) {
XCTAssertEqual(p.string, q.string, file: file, line: line)
}
private func XCTAssertEqual<P: Pathish, Q: Pathish>(_ p: P?, _ q: Q?, file: StaticString = #file, line: UInt = #line) {
XCTAssertEqual(p?.string, q?.string, file: file, line: line)
}

View File

@@ -3,7 +3,7 @@ import Foundation
class TemporaryDirectory {
let url: URL
var path: Path { return Path(string: url.path) }
var path: DynamicPath { return DynamicPath(Path(string: url.path)) }
/**
Creates a new temporary directory.
@@ -51,7 +51,7 @@ class TemporaryDirectory {
}
extension Path {
static func mktemp<T>(body: (Path) throws -> T) throws -> T {
static func mktemp<T>(body: (DynamicPath) throws -> T) throws -> T {
let tmp = try TemporaryDirectory()
return try body(tmp.path)
}

View File

@@ -13,6 +13,7 @@ extension PathTests {
("testConcatenation", testConcatenation),
("testCopyInto", testCopyInto),
("testCopyTo", testCopyTo),
("testCopyToExistingDirectoryFails", testCopyToExistingDirectoryFails),
("testDataExtensions", testDataExtensions),
("testDelete", testDelete),
("testDynamicMember", testDynamicMember),
@@ -23,6 +24,10 @@ extension PathTests {
("testFileHandleExtensions", testFileHandleExtensions),
("testFileReference", testFileReference),
("testFilesystemAttributes", testFilesystemAttributes),
("testFindExtension", testFindExtension),
("testFindKinds", testFindKinds),
("testFindMaxDepth0", testFindMaxDepth0),
("testFindMaxDepth1", testFindMaxDepth1),
("testFlatMap", testFlatMap),
("testInitializerForRelativePath", testInitializerForRelativePath),
("testIsDirectory", testIsDirectory),