.some
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.DS_Store
|
||||||
|
/.build
|
||||||
|
/*.xcodeproj
|
||||||
27
LICENSE.md
Normal file
27
LICENSE.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
Unlicense (Public Domain)
|
||||||
|
============================
|
||||||
|
|
||||||
|
This is free and unencumbered software released into the public domain.
|
||||||
|
|
||||||
|
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||||
|
distribute this software, either in source code form or as a compiled
|
||||||
|
binary, for any purpose, commercial or non-commercial, and by any
|
||||||
|
means.
|
||||||
|
|
||||||
|
In jurisdictions that recognize copyright laws, the author or authors
|
||||||
|
of this software dedicate any and all copyright interest in the
|
||||||
|
software to the public domain. We make this dedication for the benefit
|
||||||
|
of the public at large and to the detriment of our heirs and
|
||||||
|
successors. We intend this dedication to be an overt act of
|
||||||
|
relinquishment in perpetuity of all present and future rights to this
|
||||||
|
software under copyright law.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||||
|
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||||
|
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||||
|
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||||
|
OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
For more information, please refer to <<http://unlicense.org/>>
|
||||||
13
Package.swift
Normal file
13
Package.swift
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// swift-tools-version:4.2
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "Path.swift",
|
||||||
|
products: [
|
||||||
|
.library(name: "Path", targets: ["Path"]),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(name: "Path", path: "Sources"),
|
||||||
|
.testTarget(name: "PathTests", dependencies: ["Path"]),
|
||||||
|
]
|
||||||
|
)
|
||||||
145
README.md
Normal file
145
README.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# Path.swift
|
||||||
|
|
||||||
|
A file-system pathing library focused on developer experience and robust
|
||||||
|
end‐results.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// convenient static members
|
||||||
|
let home = Path.home
|
||||||
|
|
||||||
|
// pleasant joining syntax
|
||||||
|
let docs = Path.home/"Documents"
|
||||||
|
|
||||||
|
// paths are *always* absolute thus avoiding common bugs
|
||||||
|
let path = Path(userInput) ?? Path.cwd/userInput
|
||||||
|
|
||||||
|
// chainable syntax so you have less boilerplate
|
||||||
|
try Path.home.join("foo").mkpath().join("bar").chmod(0o555)
|
||||||
|
|
||||||
|
// easy file-management
|
||||||
|
try Path.root.join("foo").copy(to: Path.root.join("bar"))
|
||||||
|
|
||||||
|
// careful API to avoid common bugs
|
||||||
|
try Path.root.join("foo").copy(into: Path.root.mkdir("bar"))
|
||||||
|
// ^^ other libraries would make the `to:` form handle both these cases
|
||||||
|
// but that can easily lead to bugs where you accidentally write files that
|
||||||
|
// were meant to be directory destinations
|
||||||
|
```
|
||||||
|
|
||||||
|
Paths are just string representations, there *may not* be a real file there.
|
||||||
|
|
||||||
|
# Support mxcl
|
||||||
|
|
||||||
|
Hi, I’m Max Howell and I have written a lot of open source software, and
|
||||||
|
probably you already use some of it (Homebrew anyone?). Please help me so I
|
||||||
|
can continue to make tools and software you need and love. I appreciate it x.
|
||||||
|
|
||||||
|
<a href="https://www.patreon.com/mxcl">
|
||||||
|
<img src="https://c5.patreon.com/external/logo/become_a_patron_button@2x.png" width="160">
|
||||||
|
</a>
|
||||||
|
|
||||||
|
## Codable
|
||||||
|
|
||||||
|
We support `Codable` as you would expect:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
try JSONEncoder().encode([Path.home, Path.home/"foo"])
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
"/Users/mxcl",
|
||||||
|
"/Users/mxcl/foo",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
However, often you want to encode relative paths:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.userInfo[.relativePath] = Path.home
|
||||||
|
encoder.encode([Path.home, Path.home/"foo"])
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"foo",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note** make sure you decode with this key set *also*, otherwise we `fatal`
|
||||||
|
(unless the paths are absolute obv.)
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.userInfo[.relativePath] = Path.home
|
||||||
|
decoder.decode(from: data)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Initializing from user-input
|
||||||
|
|
||||||
|
The `Path` initializer returns `nil` unless fed an absolute path; thus to
|
||||||
|
initialize from user-input that may contain a relative path use this form:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let path = Path(userInput) ?? Path.cwd/userInput
|
||||||
|
```
|
||||||
|
|
||||||
|
This is explicit, not hiding anything that code-review may miss and preventing
|
||||||
|
common bugs like accidentally creating `Path` objects from strings you did not
|
||||||
|
expect to be relative.
|
||||||
|
|
||||||
|
## Extensions
|
||||||
|
|
||||||
|
We have some extensions to Apple APIs:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let bashProfile = try String(contentsOf: Path.home/".bash_profile")
|
||||||
|
let history = try Data(contentsOf: Path.home/".history")
|
||||||
|
|
||||||
|
bashProfile += "\n\nfoo"
|
||||||
|
|
||||||
|
try bashProfile.write(to: Path.home/".bash_profile")
|
||||||
|
|
||||||
|
try Bundle.main.resources!.join("foo").copy(to: .home)
|
||||||
|
// ^^ `-> Path?` because the underlying `Bundle` function is `-> String?`
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
for path in Path.home.ls() {
|
||||||
|
print(path.path)
|
||||||
|
print(path.kind) // .directory or .file
|
||||||
|
}
|
||||||
|
|
||||||
|
for path in Path.home.ls() where path.kind == .file {
|
||||||
|
//…
|
||||||
|
}
|
||||||
|
|
||||||
|
for path in Path.home.ls() where path.mtime > yesterday {
|
||||||
|
//…
|
||||||
|
}
|
||||||
|
|
||||||
|
let dirs = Path.home.ls().directories().filter {
|
||||||
|
//…
|
||||||
|
}
|
||||||
|
|
||||||
|
let swiftFiles = Path.home.ls().files(withExtension: "swift")
|
||||||
|
```
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
|
||||||
|
SwiftPM only:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
package.append(.package(url: "https://github.com/mxcl/Path.swift", from: "0.0.0"))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get push notifications for new releases
|
||||||
|
|
||||||
|
https://codebasesaga.com/canopy/
|
||||||
53
Sources/Extensions.swift
Normal file
53
Sources/Extensions.swift
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public extension Bundle {
|
||||||
|
func path(forResource: String, ofType: String?) -> Path? {
|
||||||
|
let f: (String?, String?) -> String? = path(forResource:ofType:)
|
||||||
|
let str = f(forResource, ofType)
|
||||||
|
return str.flatMap(Path.init)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var sharedFrameworks: Path? {
|
||||||
|
return sharedFrameworksPath.flatMap(Path.init)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var resources: Path? {
|
||||||
|
return resourcePath.flatMap(Path.init)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension String {
|
||||||
|
@inlinable
|
||||||
|
init(contentsOf path: Path) 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 {
|
||||||
|
try write(toFile: to.string, atomically: atomically, encoding: encoding)
|
||||||
|
return to
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension Data {
|
||||||
|
@inlinable
|
||||||
|
init(contentsOf path: Path) throws {
|
||||||
|
try self.init(contentsOf: path.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// - Returns: `to` to allow chaining
|
||||||
|
@inlinable
|
||||||
|
@discardableResult
|
||||||
|
func write(to: Path, atomically: Bool = false) throws -> Path {
|
||||||
|
let opts: NSData.WritingOptions
|
||||||
|
if atomically {
|
||||||
|
opts = .atomicWrite
|
||||||
|
} else {
|
||||||
|
opts = []
|
||||||
|
}
|
||||||
|
try write(to: to.url, options: opts)
|
||||||
|
return to
|
||||||
|
}
|
||||||
|
}
|
||||||
56
Sources/Path+Attributes.swift
Normal file
56
Sources/Path+Attributes.swift
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public extension Path {
|
||||||
|
/// - Note: If file is already locked, does nothing
|
||||||
|
/// - Note: If file doesn’t exist, throws
|
||||||
|
@discardableResult
|
||||||
|
public func lock() throws -> Path {
|
||||||
|
var attrs = try FileManager.default.attributesOfItem(atPath: string)
|
||||||
|
let b = attrs[.immutable] as? Bool ?? false
|
||||||
|
if !b {
|
||||||
|
attrs[.immutable] = true
|
||||||
|
try FileManager.default.setAttributes(attrs, ofItemAtPath: string)
|
||||||
|
}
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// - Note: If file isn‘t locked, does nothing
|
||||||
|
/// - Note: If file doesn’t exist, does nothing
|
||||||
|
@discardableResult
|
||||||
|
public func unlock() throws -> Path {
|
||||||
|
var attrs: [FileAttributeKey: Any]
|
||||||
|
do {
|
||||||
|
attrs = try FileManager.default.attributesOfItem(atPath: string)
|
||||||
|
} catch CocoaError.fileReadNoSuchFile {
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
let b = attrs[.immutable] as? Bool ?? false
|
||||||
|
if b {
|
||||||
|
attrs[.immutable] = false
|
||||||
|
try FileManager.default.setAttributes(attrs, ofItemAtPath: string)
|
||||||
|
}
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Sets the file’s attributes using UNIX octal notation.
|
||||||
|
|
||||||
|
Path.home.join("foo").chmod(0o555)
|
||||||
|
*/
|
||||||
|
@discardableResult
|
||||||
|
public func chmod(_ octal: Int) throws -> Path {
|
||||||
|
try FileManager.default.setAttributes([.posixPermissions: octal], ofItemAtPath: string)
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// - Returns: modification-time or creation-time if none
|
||||||
|
public var mtime: Date {
|
||||||
|
do {
|
||||||
|
let attrs = try FileManager.default.attributesOfItem(atPath: string)
|
||||||
|
return attrs[.modificationDate] as? Date ?? attrs[.creationDate] as? Date ?? Date()
|
||||||
|
} catch {
|
||||||
|
//TODO print(error)
|
||||||
|
return Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
Sources/Path+Codable.swift
Normal file
28
Sources/Path+Codable.swift
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public extension CodingUserInfoKey {
|
||||||
|
static let relativePath = CodingUserInfoKey(rawValue: "dev.mxcl.Path.relative")!
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Path: Codable {
|
||||||
|
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."))
|
||||||
|
}
|
||||||
|
string = (root/value).string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
if let root = encoder.userInfo[.relativePath] as? Path {
|
||||||
|
try container.encode(relative(to: root))
|
||||||
|
} else {
|
||||||
|
try container.encode(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
109
Sources/Path+FileManager.swift
Normal file
109
Sources/Path+FileManager.swift
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public extension Path {
|
||||||
|
/**
|
||||||
|
Copies a file.
|
||||||
|
- Note: `throws` if `to` is a directory.
|
||||||
|
- Parameter to: Destination filename.
|
||||||
|
- Parameter overwrite: If true overwrites any file that already exists at `to`.
|
||||||
|
- Returns: `to` to allow chaining
|
||||||
|
- SeeAlso: copy(into:overwrite:)
|
||||||
|
*/
|
||||||
|
@discardableResult
|
||||||
|
public func copy(to: Path, overwrite: Bool = false) throws -> Path {
|
||||||
|
if overwrite, to.exists {
|
||||||
|
try FileManager.default.removeItem(at: to.url)
|
||||||
|
}
|
||||||
|
try FileManager.default.copyItem(atPath: string, toPath: to.string)
|
||||||
|
return to
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Copies a file into a directory
|
||||||
|
|
||||||
|
If the destination does not exist, this function creates the directory first.
|
||||||
|
|
||||||
|
- Note: `throws` if `into` is a file.
|
||||||
|
- Parameter into: Destination directory
|
||||||
|
- Parameter overwrite: If true overwrites any file that already exists at `into`.
|
||||||
|
- Returns: The `Path` of the newly copied file.
|
||||||
|
- SeeAlso: copy(into:overwrite:)
|
||||||
|
*/
|
||||||
|
@discardableResult
|
||||||
|
public func copy(into: Path, overwrite: Bool = false) throws -> Path {
|
||||||
|
if !into.exists {
|
||||||
|
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||||
|
} else if overwrite, !into.isDirectory {
|
||||||
|
try into.delete()
|
||||||
|
}
|
||||||
|
let rv = into/basename()
|
||||||
|
try FileManager.default.copyItem(at: url, to: rv.url)
|
||||||
|
return rv
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func move(into: Path) throws -> Path {
|
||||||
|
if !into.exists {
|
||||||
|
try into.mkpath()
|
||||||
|
} else if !into.isDirectory {
|
||||||
|
throw CocoaError.error(.fileWriteFileExists)
|
||||||
|
}
|
||||||
|
try FileManager.default.moveItem(at: url, to: into.join(basename()).url)
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
public func delete() throws {
|
||||||
|
try FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
@discardableResult
|
||||||
|
func touch() throws -> Path {
|
||||||
|
return try "".write(to: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
@discardableResult
|
||||||
|
public func mkdir() throws -> Path {
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false, attributes: nil)
|
||||||
|
} catch CocoaError.Code.fileWriteFileExists {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
@discardableResult
|
||||||
|
public func mkpath() throws -> Path {
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
} catch CocoaError.Code.fileWriteFileExists {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// - Note: If file doesn’t exist, creates file
|
||||||
|
/// - Note: If file is not writable, makes writable first, resetting permissions after the write
|
||||||
|
@discardableResult
|
||||||
|
public func replaceContents(with contents: String, atomically: Bool = false, encoding: String.Encoding = .utf8) throws -> Path {
|
||||||
|
let resetPerms: Int?
|
||||||
|
if exists, !isWritable {
|
||||||
|
resetPerms = try FileManager.default.attributesOfItem(atPath: string)[.posixPermissions] as? Int
|
||||||
|
let perms = resetPerms ?? 0o777
|
||||||
|
try chmod(perms | 0o200)
|
||||||
|
} else {
|
||||||
|
resetPerms = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
defer {
|
||||||
|
_ = try? resetPerms.map(self.chmod)
|
||||||
|
}
|
||||||
|
|
||||||
|
try contents.write(to: self)
|
||||||
|
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
21
Sources/Path+StringConvertibles.swift
Normal file
21
Sources/Path+StringConvertibles.swift
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import class Foundation.NSString
|
||||||
|
|
||||||
|
extension Path: LosslessStringConvertible {
|
||||||
|
/// Returns `nil` unless fed an absolute path
|
||||||
|
public init?(_ description: String) {
|
||||||
|
guard description.starts(with: "/") || description.starts(with: "~/") else { return nil }
|
||||||
|
self.init(string: (description as NSString).standardizingPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Path: CustomStringConvertible {
|
||||||
|
public var description: String {
|
||||||
|
return string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Path: CustomDebugStringConvertible {
|
||||||
|
public var debugDescription: String {
|
||||||
|
return "Path(string: \(string))"
|
||||||
|
}
|
||||||
|
}
|
||||||
27
Sources/Path+ls.swift
Normal file
27
Sources/Path+ls.swift
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public extension Path {
|
||||||
|
/// same as the `ls` command ∴ is ”shallow”
|
||||||
|
func ls() throws -> [Entry] {
|
||||||
|
let relativePaths = try FileManager.default.contentsOfDirectory(atPath: string)
|
||||||
|
func convert(relativePath: String) -> Entry {
|
||||||
|
let path = self/relativePath
|
||||||
|
return Entry(kind: path.isDirectory ? .directory : .file, path: path)
|
||||||
|
}
|
||||||
|
return relativePaths.map(convert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension Array where Element == Path.Entry {
|
||||||
|
var directories: [Path] {
|
||||||
|
return compactMap {
|
||||||
|
$0.kind == .directory ? $0.path : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func files(withExtension ext: String) -> [Path] {
|
||||||
|
return compactMap {
|
||||||
|
$0.kind == .file && $0.path.extension == ext ? $0.path : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
Sources/Path->Bool.swift
Normal file
21
Sources/Path->Bool.swift
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public extension Path {
|
||||||
|
var isWritable: Bool {
|
||||||
|
return FileManager.default.isWritableFile(atPath: string)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isDirectory: Bool {
|
||||||
|
var isDir: ObjCBool = false
|
||||||
|
return FileManager.default.fileExists(atPath: string, isDirectory: &isDir) && isDir.boolValue
|
||||||
|
}
|
||||||
|
|
||||||
|
var isFile: Bool {
|
||||||
|
var isDir: ObjCBool = true
|
||||||
|
return FileManager.default.fileExists(atPath: string, isDirectory: &isDir) && !isDir.boolValue
|
||||||
|
}
|
||||||
|
|
||||||
|
var exists: Bool {
|
||||||
|
return FileManager.default.fileExists(atPath: string)
|
||||||
|
}
|
||||||
|
}
|
||||||
102
Sources/Path.swift
Normal file
102
Sources/Path.swift
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct Path: Equatable, Hashable, Comparable {
|
||||||
|
public let string: String
|
||||||
|
|
||||||
|
public static var cwd: Path {
|
||||||
|
return Path(string: FileManager.default.currentDirectoryPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static var root: Path {
|
||||||
|
return Path(string: "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
public static var home: Path {
|
||||||
|
return Path(string: NSHomeDirectory())
|
||||||
|
}
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
public var `extension`: String {
|
||||||
|
return (string as NSString).pathExtension
|
||||||
|
}
|
||||||
|
|
||||||
|
/// - Note: always returns a valid path, `Path.root.parent` *is* `Path.root`
|
||||||
|
public var parent: Path {
|
||||||
|
return Path(string: (string as NSString).deletingLastPathComponent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
public var url: URL {
|
||||||
|
return URL(fileURLWithPath: string)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func basename(dropExtension: Bool = false) -> String {
|
||||||
|
let str = string as NSString
|
||||||
|
if !dropExtension {
|
||||||
|
return str.lastPathComponent
|
||||||
|
} else {
|
||||||
|
let ext = str.pathExtension
|
||||||
|
if !ext.isEmpty {
|
||||||
|
return String(str.lastPathComponent.dropLast(ext.count + 1))
|
||||||
|
} else {
|
||||||
|
return str.lastPathComponent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO another variant that returns `nil` if result would start with `..`
|
||||||
|
public func relative(to base: Path) -> 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
|
||||||
|
let baseComps = (base.string as NSString).pathComponents
|
||||||
|
|
||||||
|
// It's common for the base to be an ancestor, so try that first.
|
||||||
|
if pathComps.starts(with: baseComps) {
|
||||||
|
// Special case, which is a plain path without `..` components. It
|
||||||
|
// might be an empty path (when self and the base are equal).
|
||||||
|
let relComps = pathComps.dropFirst(baseComps.count)
|
||||||
|
return relComps.joined(separator: "/")
|
||||||
|
} else {
|
||||||
|
// General case, in which we might well need `..` components to go
|
||||||
|
// "up" before we can go "down" the directory tree.
|
||||||
|
var newPathComps = ArraySlice(pathComps)
|
||||||
|
var newBaseComps = ArraySlice(baseComps)
|
||||||
|
while newPathComps.prefix(1) == newBaseComps.prefix(1) {
|
||||||
|
// First component matches, so drop it.
|
||||||
|
newPathComps = newPathComps.dropFirst()
|
||||||
|
newBaseComps = newBaseComps.dropFirst()
|
||||||
|
}
|
||||||
|
// Now construct a path consisting of as many `..`s as are in the
|
||||||
|
// `newBaseComps` followed by what remains in `newPathComps`.
|
||||||
|
var relComps = Array(repeating: "..", count: newBaseComps.count)
|
||||||
|
relComps.append(contentsOf: newPathComps)
|
||||||
|
return relComps.joined(separator: "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func join<S>(_ part: S) -> Path where S: StringProtocol {
|
||||||
|
//TODO standardizingPath does more than we want really (eg tilde expansion)
|
||||||
|
let str = (string as NSString).appendingPathComponent(String(part))
|
||||||
|
return Path(string: (str as NSString).standardizingPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
public static func <(lhs: Path, rhs: Path) -> Bool {
|
||||||
|
return lhs.string.compare(rhs.string, locale: .current) == .orderedAscending
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Entry {
|
||||||
|
public enum Kind {
|
||||||
|
case file
|
||||||
|
case directory
|
||||||
|
}
|
||||||
|
public let kind: Kind
|
||||||
|
public let path: Path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@inlinable
|
||||||
|
public func /<S>(lhs: Path, rhs: S) -> Path where S: StringProtocol {
|
||||||
|
return lhs.join(rhs)
|
||||||
|
}
|
||||||
21
Sources/TemporaryDirectory.swift
Normal file
21
Sources/TemporaryDirectory.swift
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public class TemporaryDirectory {
|
||||||
|
public let url: URL
|
||||||
|
public var path: Path { return Path(string: url.path) }
|
||||||
|
|
||||||
|
public init() throws {
|
||||||
|
url = try FileManager.default.url(for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: URL(fileURLWithPath: "/"), create: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
_ = try? FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension Path {
|
||||||
|
static func mktemp<T>(body: (Path) throws -> T) throws -> T {
|
||||||
|
let tmp = try TemporaryDirectory()
|
||||||
|
return try body(tmp.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
8
Tests/LinuxMain.swift
Normal file
8
Tests/LinuxMain.swift
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
import PathTests
|
||||||
|
|
||||||
|
var tests = [XCTestCaseEntry]()
|
||||||
|
tests += PathTests.__allTests()
|
||||||
|
|
||||||
|
XCTMain(tests)
|
||||||
96
Tests/PathTests/PathTests.swift
Normal file
96
Tests/PathTests/PathTests.swift
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import XCTest
|
||||||
|
import Path
|
||||||
|
|
||||||
|
class PathTests: XCTestCase {
|
||||||
|
func testConcatenation() {
|
||||||
|
XCTAssertEqual((Path.root/"bar").string, "/bar")
|
||||||
|
XCTAssertEqual(Path.cwd.string, FileManager.default.currentDirectoryPath)
|
||||||
|
XCTAssertEqual((Path.root/"/bar").string, "/bar")
|
||||||
|
XCTAssertEqual((Path.root/"///bar").string, "/bar")
|
||||||
|
XCTAssertEqual((Path.root/"foo///bar////").string, "/foo/bar")
|
||||||
|
XCTAssertEqual((Path.root/"foo"/"/bar").string, "/foo/bar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEnumeration() throws {
|
||||||
|
let tmpdir_ = try TemporaryDirectory()
|
||||||
|
let tmpdir = tmpdir_.path
|
||||||
|
try tmpdir.join("a").mkdir().join("c").touch()
|
||||||
|
try tmpdir.join("b").touch()
|
||||||
|
try tmpdir.join("c").touch()
|
||||||
|
|
||||||
|
var paths = Set<String>()
|
||||||
|
var dirs = 0
|
||||||
|
for entry in try tmpdir.ls() {
|
||||||
|
if entry.kind == .directory {
|
||||||
|
dirs += 1
|
||||||
|
}
|
||||||
|
paths.insert(entry.path.basename())
|
||||||
|
}
|
||||||
|
XCTAssertEqual(dirs, 1)
|
||||||
|
XCTAssertEqual(paths, ["a", "b", "c"])
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testExists() {
|
||||||
|
XCTAssert(Path.root.exists)
|
||||||
|
XCTAssert((Path.root/"Users").exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIsDirectory() {
|
||||||
|
XCTAssert(Path.root.isDirectory)
|
||||||
|
XCTAssert((Path.root/"Users").isDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMktemp() throws {
|
||||||
|
var path: Path!
|
||||||
|
try Path.mktemp {
|
||||||
|
path = $0
|
||||||
|
XCTAssert(path.isDirectory)
|
||||||
|
}
|
||||||
|
XCTAssert(!path.exists)
|
||||||
|
XCTAssert(!path.isDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMkpathIfExists() throws {
|
||||||
|
try Path.mktemp {
|
||||||
|
for _ in 0...1 {
|
||||||
|
try $0.join("a").mkdir()
|
||||||
|
try $0.join("b/c").mkpath()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBasename() {
|
||||||
|
XCTAssertEqual(Path.root.join("foo.bar").basename(dropExtension: true), "foo")
|
||||||
|
XCTAssertEqual(Path.root.join("foo").basename(dropExtension: true), "foo")
|
||||||
|
XCTAssertEqual(Path.root.join("foo.").basename(dropExtension: true), "foo.")
|
||||||
|
XCTAssertEqual(Path.root.join("foo.bar.baz").basename(dropExtension: true), "foo.bar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCodable() throws {
|
||||||
|
let input = [Path.root/"bar"]
|
||||||
|
XCTAssertEqual(try JSONDecoder().decode([Path].self, from: try JSONEncoder().encode(input)), input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRelativePathCodable() throws {
|
||||||
|
let root = Path.root/"bar"
|
||||||
|
let input = [
|
||||||
|
root/"foo"
|
||||||
|
]
|
||||||
|
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.userInfo[.relativePath] = root
|
||||||
|
let data = try encoder.encode(input)
|
||||||
|
|
||||||
|
XCTAssertEqual(try JSONSerialization.jsonObject(with: data) as? [String], ["foo"])
|
||||||
|
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
XCTAssertThrowsError(try decoder.decode([Path].self, from: data))
|
||||||
|
decoder.userInfo[.relativePath] = root
|
||||||
|
XCTAssertEqual(try decoder.decode([Path].self, from: data), input)
|
||||||
|
}
|
||||||
|
}
|
||||||
24
Tests/PathTests/XCTestManifests.swift
Normal file
24
Tests/PathTests/XCTestManifests.swift
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
extension PathTests {
|
||||||
|
static let __allTests = [
|
||||||
|
("testBasename", testBasename),
|
||||||
|
("testCodable", testCodable),
|
||||||
|
("testConcatenation", testConcatenation),
|
||||||
|
("testEnumeration", testEnumeration),
|
||||||
|
("testExists", testExists),
|
||||||
|
("testIsDirectory", testIsDirectory),
|
||||||
|
("testMkpathIfExists", testMkpathIfExists),
|
||||||
|
("testMktemp", testMktemp),
|
||||||
|
("testRelativePathCodable", testRelativePathCodable),
|
||||||
|
("testRelativeTo", testRelativeTo),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#if !os(macOS)
|
||||||
|
public func __allTests() -> [XCTestCaseEntry] {
|
||||||
|
return [
|
||||||
|
testCase(PathTests.__allTests),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
#endif
|
||||||
Reference in New Issue
Block a user