This commit is contained in:
Max Howell
2019-01-17 16:33:38 -05:00
parent 8e0d766924
commit 97e9cbaa8f
16 changed files with 754 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.DS_Store
/.build
/*.xcodeproj

27
LICENSE.md Normal file
View 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 &lt;<http://unlicense.org/>&gt;

13
Package.swift Normal file
View 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
View File

@@ -0,0 +1,145 @@
# Path.swift
A file-system pathing library focused on developer experience and robust
endresults.
```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, Im 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
View 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
}
}

View File

@@ -0,0 +1,56 @@
import Foundation
public extension Path {
/// - Note: If file is already locked, does nothing
/// - Note: If file doesnt 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 isnt locked, does nothing
/// - Note: If file doesnt 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 files 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()
}
}
}

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

View 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 doesnt 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
}
}

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

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

@@ -0,0 +1,8 @@
import XCTest
import PathTests
var tests = [XCTestCaseEntry]()
tests += PathTests.__allTests()
XCTMain(tests)

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

View 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