Compare commits

...

42 Commits
0.2.2 ... 0.5.0

Author SHA1 Message Date
Max Howell
db135e32c8 Tag 0.5.0 2019-01-25 11:08:57 -05:00
Max Howell
bfcc48db20 Merge pull request #10 from mxcl/fixes
Swift 5 / Xcode 10.2 / Fixes
2019-01-25 11:04:35 -05:00
Max Howell
b0bf0d0074 This is not yet fixed in Linux Swift 5.0 2019-01-25 10:55:02 -05:00
Max Howell
5f364fe21b Test against Swift 5 snapshot 2019-01-25 10:40:10 -05:00
Max Howell
fdff3bcc05 Swift 5 --warnings 2019-01-24 15:02:06 -05:00
Max Howell
2388c384a1 Swift 5 Manifest (untested until Travis catches up) 2019-01-24 15:01:53 -05:00
Max Howell
80960f5876 Don’t overwrite a file with a directory 2019-01-24 14:44:01 -05:00
Max Howell
9eca479f7b Fix mtime return so it is as per doc contract 2019-01-24 14:43:45 -05:00
Max Howell
ca9f1e0a74 Parallelize tests 2019-01-23 11:25:51 -05:00
Max Howell
de4fb3ae47 Remove replaceContents, user can use String.write 2019-01-22 17:57:28 -05:00
Max Howell
58d026c8a9 Fix copy(into:) overwrite mode
Seems like Linux Foundation has a bug

I checked, seems fixed in Swift 5. But added a swift version check se we can verify and if not report the bug.
2019-01-22 15:26:04 -05:00
Max Howell
43d3e0a745 Improve docs 2019-01-21 14:30:12 -05:00
Max Howell
21fb03b9d9 Test .. paths work with join 2019-01-21 14:26:56 -05:00
Max Howell
3333c731d3 Fix Travis 2019-01-21 12:57:13 -05:00
Max Howell
e15173cfbc Merge pull request #9 from LucianoPAlmeida/is-path-conveniece-add
Adding convenience extensions on Path->Bool
2019-01-20 19:28:05 -05:00
Luciano Almeida
7be264a38e Adding convenience extensions on Path->Bool 2019-01-20 22:13:13 -02:00
Max Howell
aac81b85a4 Merge pull request #8 from mxcl/better-deploy
Better deploy
2019-01-20 18:53:11 -05:00
Max Howell
3644124a36 Fix testing on tvOS/iOS 2019-01-20 18:44:24 -05:00
Max Howell
ca4ac3ec8f Fix stage types in .travis.yml 2019-01-20 17:33:56 -05:00
Max Howell
920f007660 Fix Linux testEnumerationSkippingHiddenFiles() 2019-01-20 17:32:39 -05:00
Max Howell
751b855a26 Pretest that fails if Linux tests aren't current 2019-01-20 17:25:46 -05:00
Max Howell
c0e5023632 Better deployment of Jazzy and Pods 2019-01-20 17:13:03 -05:00
Max Howell
e0c62108e8 Update Linux tests 2019-01-20 16:30:02 -05:00
Max Howell
5cc2fcbf30 Tag 0.4.1 2019-01-20 16:26:22 -05:00
Max Howell
7595c601e8 Attempt custom Jazzy index/contents 2019-01-20 16:24:06 -05:00
Luciano Almeida
d8ea357459 Adding ls -a like functionality to Path.ls() 2019-01-20 16:23:40 -05:00
Max Howell
86798755be Deploy to CocoaPods 2019-01-20 13:37:34 -05:00
Max Howell
152ad8a8ae Implementations of CommonDirs for Linux 2019-01-20 13:03:38 -05:00
Max Howell
a98ba37e59 Merge pull request #1 from itsallmememe/master
Common Directories
2019-01-20 12:30:29 -05:00
Max Howell
1c2cffada5 Support CocoaPods 2019-01-19 22:12:06 -05:00
Max Howell
6c24de4875 Fix actually deploying 2019-01-19 15:08:11 -05:00
Max Howell
6df12b3bb9 Refer to alternative packages 2019-01-19 15:07:47 -05:00
Max Howell
d52cdf96c4 Merge pull request #4 from mxcl/more-docs
More docs
2019-01-19 14:46:46 -05:00
Max Howell
3eda9a9741 100% documentation please 2019-01-19 14:36:27 -05:00
Max Howell
29149da72b Use FileManager.homeDirectory where possible 2019-01-19 14:35:42 -05:00
Max Howell
cd30e89808 Remove TemporaryDirectory
This violates the responsibility of this framework.
2019-01-19 14:20:12 -05:00
Max Howell
9f76eeb507 Link to Jazzy docs 2019-01-19 12:55:18 -05:00
Max Howell
b5bdaa6ceb Fix Jazzy root-url 2019-01-19 12:52:47 -05:00
Max Howell
17dd706115 Merge pull request #3 from mxcl/jazzy
Generate Jazzy docs in Travis
2019-01-19 12:40:21 -05:00
Max Howell
903038ae80 Generate Jazzy docs in Travis 2019-01-19 12:34:35 -05:00
Max Howell
f56811d64f Document various rules and caveats 2019-01-18 17:47:04 -05:00
Niall McCormack
12c7b348d6 added hooks for common directories - Documents, Caches and Application Support 2019-01-19 00:24:20 +08:00
15 changed files with 560 additions and 96 deletions

View File

@@ -1,6 +1,12 @@
# only run for: merge commits, releases and pull-requests # 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+)?(-\S*)?$/
stages:
- name: pretest
- name: test
- name: deploy
if: branch =~ ^\d+\.\d+\.\d+$
os: osx os: osx
language: swift language: swift
osx_image: xcode10.1 osx_image: xcode10.1
@@ -9,18 +15,18 @@ xcode_scheme: Path.swift-Package
jobs: jobs:
include: include:
- script: swift test - script: swift test --parallel
name: macOS name: macOS / Swift 4.2.1
- &xcodebuild - &xcodebuild
before_install: swift package generate-xcodeproj before_install: swift package generate-xcodeproj
xcode_destination: platform=iOS Simulator,OS=latest,name=iPhone XS xcode_destination: platform=iOS Simulator,OS=latest,name=iPhone XS
name: iOS name: iOS / Swift 4.2.1
- <<: *xcodebuild - <<: *xcodebuild
xcode_destination: platform=tvOS Simulator,OS=latest,name=Apple TV xcode_destination: platform=tvOS Simulator,OS=latest,name=Apple TV
name: tvOS name: tvOS / Swift 4.2.1
- <<: *xcodebuild - <<: *xcodebuild
name: watchOS name: watchOS / Swift 4.2.1
script: | script: |
set -o pipefail set -o pipefail
xcodebuild \ xcodebuild \
@@ -29,12 +35,77 @@ jobs:
-destination 'platform=watchOS Simulator,OS=latest,name=Apple Watch Series 4 - 40mm' \ -destination 'platform=watchOS Simulator,OS=latest,name=Apple Watch Series 4 - 40mm' \
build | xcpretty build | xcpretty
- &linux
- env: SWIFT_VERSION=4.2.1 env: SWIFT_VERSION=4.2.1
os: linux os: linux
name: Linux name: Linux / Swift 4.2.1
language: generic language: generic
dist: trusty dist: trusty
sudo: false sudo: false
install: eval "$(curl -sL https://swiftenv.fuller.li/install.sh)" install: eval "$(curl -sL https://swiftenv.fuller.li/install.sh)"
script: swift test script: swift test --parallel
- <<: *linux
env: SWIFT_VERSION='5.0-DEVELOPMENT-SNAPSHOT-2019-01-22-a'
name: Linux / Swift 5.0.0-dev (2019-01-22)
- stage: pretest
name: Check Linux tests are syncd
install: swift test --generate-linuxmain
script: git diff --exit-code
- stage: deploy
name: Jazzy
before_install: |
cat <<\ \ EOF> .jazzy.yaml
module: Path
module_version: TRAVIS_TAG
custom_categories:
- name: Path
children:
- Path
- /(_:_:)
xcodebuild_arguments:
- UseModernBuildSystem=NO
output: output
github_url: https://github.com/mxcl/Path.swift
EOF
sed -i '' "s/TRAVIS_TAG/$TRAVIS_TAG/" .jazzy.yaml
# ^^ this weirdness because Travis multiline YAML is broken and inserts
# two spaces in front of the output which means we need a prefixed
# delimiter which also weirdly stops bash from doing variable substitution
install: gem install jazzy
before_script: swift package generate-xcodeproj
script: jazzy
deploy:
provider: pages
skip-cleanup: true
github-token: $GITHUB_TOKEN
local-dir: output
on:
tags: true
- name: CocoaPods
before_install: |
cat <<\ \ EOF> Path.swift.podspec
Pod::Spec.new do |s|
s.name = 'Path.swift'
s.version = 'TRAVIS_TAG'
s.summary = 'Delightful, robust file-pathing functions'
s.homepage = 'https://github.com/mxcl/Path.swift'
s.license = { :type => 'Unlicense', :file => 'LICENSE.md' }
s.author = { 'mxcl' => 'mxcl@me.com' }
s.source = { :git => 'https://github.com/mxcl/Path.swift.git', :tag => s.version.to_s }
s.social_media_url = 'https://twitter.com/mxcl'
s.osx.deployment_target = '10.10'
s.ios.deployment_target = '8.0'
s.tvos.deployment_target = '10.0'
s.watchos.deployment_target = '3.0'
s.source_files = 'Sources/*'
s.swift_version = '4.2'
end
EOF
sed -i '' "s/TRAVIS_TAG/$TRAVIS_TAG/" Path.swift.podspec
# ^^ see the Jazzy deployment for explanation
install: gem install cocoapods --pre
script: pod trunk push

20
Package@swift-5.0.swift Normal file
View File

@@ -0,0 +1,20 @@
// swift-tools-version:5.0
import PackageDescription
let pkg = Package(
name: "Path.swift",
products: [
.library(name: "Path", targets: ["Path"]),
],
targets: [
.target(name: "Path", path: "Sources"),
.testTarget(name: "PathTests", dependencies: ["Path"]),
]
)
pkg.platforms = [
.macOS(.v10_10), .iOS(.v8), .tvOS(.v10), .watchOS(.v3)
]
pkg.swiftLanguageVersions = [
.v4_2, .v5
]

View File

@@ -15,20 +15,29 @@ let docs = Path.home/"Documents"
// paths are *always* absolute thus avoiding common bugs // paths are *always* absolute thus avoiding common bugs
let path = Path(userInput) ?? Path.cwd/userInput let path = Path(userInput) ?? Path.cwd/userInput
// chainable syntax so you have less boilerplate // elegant, chainable syntax
try Path.home.join("foo").mkpath().join("bar").chmod(0o555) try Path.home.join("foo").mkdir().join("bar").touch().chmod(0o555)
// sensible considerations
try Path.home.join("bar").mkdir()
try Path.home.join("bar").mkdir() // doesnt throw we already have the desired result
// easy file-management // easy file-management
try Path.root.join("foo").copy(to: Path.root.join("bar")) let bar = try Path.root.join("foo").copy(to: Path.root/"bar")
print(bar) // => /bar
print(bar.isFile) // => true
// careful API to avoid common bugs // careful API considerations so as to avoid common bugs
try Path.root.join("foo").copy(into: Path.root.mkdir("bar")) let foo = try Path.root.join("foo").copy(into: Path.root.mkdir("bar"))
// ^^ other libraries would make the `to:` form handle both these cases print(foo) // => /bar/foo
// but that can easily lead to bugs where you accidentally write files that print(foo.isFile) // => true
// were meant to be directory destinations
// A practical example: installing a helper executable
try Bundle.resources.join("helper").copy(into: Path.home.join(".local/bin").mkpath()).chmod(0o500)
``` ```
Paths are just string representations, there *might not* be a real file there. We emphasize safety and correctness, just like Swift, and also (again like
Swift), we provide a thoughtful and comprehensive (yet concise) API.
# Support mxcl # Support mxcl
@@ -40,6 +49,12 @@ can continue to make tools and software you need and love. I appreciate it x.
<img src="https://c5.patreon.com/external/logo/become_a_patron_button@2x.png" width="160"> <img src="https://c5.patreon.com/external/logo/become_a_patron_button@2x.png" width="160">
</a> </a>
[Other donation/tipping options](http://mxcl.github.io/donate/)
# Handbook
Our [online API documentation] is automatically updated for new releases.
## Codable ## Codable
We support `Codable` as you would expect: We support `Codable` as you would expect:
@@ -92,6 +107,11 @@ 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 common bugs like accidentally creating `Path` objects from strings you did not
expect to be relative. expect to be relative.
Our initializer is nameless because we conform to `LosslessStringConvertible`,
the same conformance as that `Int`, `Float` etc. conform. The protocol enforces
a nameless initialization and since it is appropriate for us to conform to it,
we do.
## Extensions ## Extensions
We have some extensions to Apple APIs: We have some extensions to Apple APIs:
@@ -134,18 +154,72 @@ let dirs = Path.home.ls().directories().filter {
let swiftFiles = Path.home.ls().files(withExtension: "swift") let swiftFiles = Path.home.ls().files(withExtension: "swift")
``` ```
# Installation # Rules & Caveats
SwiftPM only: Paths are just string representations, there *might not* be a real file there.
```swift ```swift
package.append(.package(url: "https://github.com/mxcl/Path.swift", from: "0.0.0")) Path.home/"b" // => /Users/mxcl/b
// joining multiple strings works as youd expect
Path.home/"b"/"c" // => /Users/mxcl/b/c
// joining multiple parts at a time is fine
Path.home/"b/c" // => /Users/mxcl/b/c
// joining with absolute paths omits prefixed slash
Path.home/"/b" // => /Users/mxcl/b
// of course, feel free to join variables:
let b = "b"
let c = "c"
Path.home/b/c // => /Users/mxcl/b/c
// tilde is not special here
Path.root/"~b" // => /~b
Path.root/"~/b" // => /~/b
// but is here
Path("~/foo")! // => /Users/mxcl/foo
// this does not work though
Path("~foo") // => nil
``` ```
# Installation
SwiftPM:
```swift
package.append(.package(url: "https://github.com/mxcl/Path.swift", from: "0.5.0"))
```
CocoaPods:
```ruby
pod 'Path.swift' ~> '0.5.0'
```
Carthage:
> Waiting on: [@Carthage#1945](https://github.com/Carthage/Carthage/pull/1945).
## Please note
We are pre 1.0, thus we can change the API as we like, and we will (to the
pursuit of getting it *right*)! We will tag 1.0 as soon as possible.
### Get push notifications for new releases ### Get push notifications for new releases
https://codebasesaga.com/canopy/ https://codebasesaga.com/canopy/
# Alternatives
* [PathKit](https://github.com/kylef/PathKit) by Kyle Fuller
* [Files](https://github.com/JohnSundell/Files) by John Sundell
* [Utility](https://github.com/apple/swift-package-manager) by Apple
[badge-platforms]: https://img.shields.io/badge/platforms-macOS%20%7C%20Linux%20%7C%20iOS%20%7C%20tvOS%20%7C%20watchOS-lightgrey.svg [badge-platforms]: https://img.shields.io/badge/platforms-macOS%20%7C%20Linux%20%7C%20iOS%20%7C%20tvOS%20%7C%20watchOS-lightgrey.svg
[badge-languages]: https://img.shields.io/badge/swift-4.2-orange.svg [badge-languages]: https://img.shields.io/badge/swift-4.2-orange.svg
[online API documentation]: https://mxcl.github.io/Path.swift/Structs/Path.html

View File

@@ -1,26 +1,33 @@
import Foundation import Foundation
/// Extensions on Foundations `Bundle` so you get `Path` rather than `String` or `URL`.
public extension Bundle { public extension Bundle {
/// Returns the path for requested resource in this bundle.
func path(forResource: String, ofType: String?) -> Path? { func path(forResource: String, ofType: String?) -> Path? {
let f: (String?, String?) -> String? = path(forResource:ofType:) let f: (String?, String?) -> String? = path(forResource:ofType:)
let str = f(forResource, ofType) let str = f(forResource, ofType)
return str.flatMap(Path.init) return str.flatMap(Path.init)
} }
public var sharedFrameworks: Path? { /// Returns the path for the shared-frameworks directory in this bundle.
var sharedFrameworks: Path? {
return sharedFrameworksPath.flatMap(Path.init) return sharedFrameworksPath.flatMap(Path.init)
} }
public var resources: Path? { /// Returns the path for the resources directory in this bundle.
var resources: Path? {
return resourcePath.flatMap(Path.init) return resourcePath.flatMap(Path.init)
} }
public var path: Path { /// Returns the path for this bundle.
var path: Path {
return Path(string: bundlePath) return Path(string: bundlePath)
} }
} }
/// Extensions on `String` that work with `Path` rather than `String` or `URL`
public extension String { public extension String {
/// Initializes this `String` with the contents of the provided path.
@inlinable @inlinable
init(contentsOf path: Path) throws { init(contentsOf path: Path) throws {
try self.init(contentsOfFile: path.string) try self.init(contentsOfFile: path.string)
@@ -35,7 +42,9 @@ public extension String {
} }
} }
/// Extensions on `Data` that work with `Path` rather than `String` or `URL`
public extension Data { public extension Data {
/// Initializes this `Data` with the contents of the provided path.
@inlinable @inlinable
init(contentsOf path: Path) throws { init(contentsOf path: Path) throws {
try self.init(contentsOf: path.url) try self.init(contentsOf: path.url)

View File

@@ -4,7 +4,7 @@ public extension Path {
/// - Note: If file is already locked, does nothing /// - Note: If file is already locked, does nothing
/// - Note: If file doesnt exist, throws /// - Note: If file doesnt exist, throws
@discardableResult @discardableResult
public func lock() throws -> Path { func lock() throws -> Path {
var attrs = try FileManager.default.attributesOfItem(atPath: string) var attrs = try FileManager.default.attributesOfItem(atPath: string)
let b = attrs[.immutable] as? Bool ?? false let b = attrs[.immutable] as? Bool ?? false
if !b { if !b {
@@ -17,7 +17,7 @@ public extension Path {
/// - Note: If file isnt locked, does nothing /// - Note: If file isnt locked, does nothing
/// - Note: If file doesnt exist, does nothing /// - Note: If file doesnt exist, does nothing
@discardableResult @discardableResult
public func unlock() throws -> Path { func unlock() throws -> Path {
var attrs: [FileAttributeKey: Any] var attrs: [FileAttributeKey: Any]
do { do {
attrs = try FileManager.default.attributesOfItem(atPath: string) attrs = try FileManager.default.attributesOfItem(atPath: string)
@@ -38,19 +38,23 @@ public extension Path {
Path.home.join("foo").chmod(0o555) Path.home.join("foo").chmod(0o555)
*/ */
@discardableResult @discardableResult
public func chmod(_ octal: Int) throws -> Path { func chmod(_ octal: Int) throws -> Path {
try FileManager.default.setAttributes([.posixPermissions: octal], ofItemAtPath: string) try FileManager.default.setAttributes([.posixPermissions: octal], ofItemAtPath: string)
return self return self
} }
/// - Returns: modification-time or creation-time if none /**
public var mtime: Date { Returns the modification-time.
- Note: Returns the creation time if there is no modification time.
- Note: Returns UNIX-time-zero if neither are available, though this *should* be impossible.
*/
var mtime: Date {
do { do {
let attrs = try FileManager.default.attributesOfItem(atPath: string) let attrs = try FileManager.default.attributesOfItem(atPath: string)
return attrs[.modificationDate] as? Date ?? attrs[.creationDate] as? Date ?? Date() return attrs[.modificationDate] as? Date ?? attrs[.creationDate] as? Date ?? Date(timeIntervalSince1970: 0)
} catch { } catch {
//TODO print(error) //TODO log error
return Date() return Date(timeIntervalSince1970: 0)
} }
} }
} }

View File

@@ -1,9 +1,12 @@
import Foundation import Foundation
/// Provided for relative-path coding. See the instructions in our `README`.
public extension CodingUserInfoKey { public extension CodingUserInfoKey {
/// If set paths are encoded as relative to this path.
static let relativePath = CodingUserInfoKey(rawValue: "dev.mxcl.Path.relative")! static let relativePath = CodingUserInfoKey(rawValue: "dev.mxcl.Path.relative")!
} }
/// Provided for relative-path coding. See the instructions in our `README`.
extension Path: Codable { extension Path: Codable {
public init(from decoder: Decoder) throws { public init(from decoder: Decoder) throws {
let value = try decoder.singleValueContainer().decode(String.self) let value = try decoder.singleValueContainer().decode(String.self)

View File

@@ -0,0 +1,90 @@
import Foundation
extension Path {
/// Returns a `Path` containing ``FileManager.default.currentDirectoryPath`.
public static var cwd: Path {
return Path(string: FileManager.default.currentDirectoryPath)
}
/// Returns a `Path` representing the root path.
public static var root: Path {
return Path(string: "/")
}
/// Returns a `Path` representing the users home directory
public static var home: Path {
let string: String
#if os(macOS)
if #available(OSX 10.12, *) {
string = FileManager.default.homeDirectoryForCurrentUser.path
} else {
string = NSHomeDirectory()
}
#else
string = NSHomeDirectory()
#endif
return Path(string: string)
}
/// Helper to allow search path and domain mask to be passed in.
private static func path(for searchPath: FileManager.SearchPathDirectory) -> Path {
#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 }
switch searchPath {
case .documentDirectory:
return Path.home/"Documents"
case .applicationSupportDirectory:
return foo("XDG_DATA_HOME", Path.home/".local/share")
case .cachesDirectory:
return foo("XDG_CACHE_HOME", Path.home/".cache")
default:
fatalError()
}
#else
guard let pathString = FileManager.default.urls(for: searchPath, in: .userDomainMask).first?.path else {
switch searchPath {
case .documentDirectory:
return Path.home/"Documents"
case .applicationSupportDirectory:
return Path.home/"Library/Application Support"
case .cachesDirectory:
return Path.home/"Library/Caches"
default:
fatalError()
}
}
return Path(string: pathString)
#endif
}
/**
The root for user documents.
- 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 {
return path(for: .documentDirectory)
}
/**
The root for cache files.
- Note: On Linux this is 'XDG_CACHE_HOME'.
- Note: You should create a subdirectory before creating any files.
*/
public static var caches: Path {
return path(for: .cachesDirectory)
}
/**
For data that supports your running application.
- Note: On Linux is `XDG_DATA_HOME`.
- Note: You should create a subdirectory before creating any files.
*/
public static var applicationSupport: Path {
return path(for: .applicationSupportDirectory)
}
}

View File

@@ -5,13 +5,14 @@ public extension Path {
Copies a file. Copies a file.
- Note: `throws` if `to` is a directory. - Note: `throws` if `to` is a directory.
- Parameter to: Destination filename. - Parameter to: Destination filename.
- Parameter overwrite: If true overwrites any file that already exists at `to`. - Parameter overwrite: If `true` and both `self` and `to` are files, overwrites `to`.
- Note: If either `self` or `to are directories, `overwrite` is ignored.
- Returns: `to` to allow chaining - Returns: `to` to allow chaining
- SeeAlso: copy(into:overwrite:) - SeeAlso: `copy(into:overwrite:)`
*/ */
@discardableResult @discardableResult
public func copy(to: Path, overwrite: Bool = false) throws -> Path { func copy(to: Path, overwrite: Bool = false) throws -> Path {
if overwrite, to.exists { if overwrite, to.isFile, isFile {
try FileManager.default.removeItem(at: to.url) try FileManager.default.removeItem(at: to.url)
} }
try FileManager.default.copyItem(atPath: string, toPath: to.string) try FileManager.default.copyItem(atPath: string, toPath: to.string)
@@ -23,26 +24,47 @@ public extension Path {
If the destination does not exist, this function creates the directory first. If the destination does not exist, this function creates the directory first.
// Create ~/.local/bin, copy `ls` there and make the new copy executable
try Path.root.join("bin/ls").copy(into: Path.home.join(".local/bin").mkpath()).chmod(0o500)
- Note: `throws` if `into` is a file. - Note: `throws` if `into` is a file.
- Parameter into: Destination directory - Parameter into: Destination directory
- Parameter overwrite: If true overwrites any file that already exists at `into`. - Parameter overwrite: If true overwrites any file that already exists at `into`.
- Returns: The `Path` of the newly copied file. - Returns: The `Path` of the newly copied file.
- SeeAlso: copy(into:overwrite:) - SeeAlso: `copy(into:overwrite:)`
*/ */
@discardableResult @discardableResult
public func copy(into: Path, overwrite: Bool = false) throws -> Path { func copy(into: Path, overwrite: Bool = false) throws -> Path {
if !into.exists { if !into.exists {
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
} else if overwrite, !into.isDirectory {
try into.delete()
} }
let rv = into/basename() let rv = into/basename()
if overwrite, rv.isFile {
try rv.delete()
}
#if os(Linux)
#if swift(>=5.1)
// check if fixed
#else
if !overwrite, rv.isFile {
throw CocoaError.error(.fileWriteFileExists)
}
#endif
#endif
try FileManager.default.copyItem(at: url, to: rv.url) try FileManager.default.copyItem(at: url, to: rv.url)
return rv return rv
} }
/**
Moves 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: move(into:overwrite:)
*/
@discardableResult @discardableResult
public func move(to: Path, overwrite: Bool = false) throws -> Path { func move(to: Path, overwrite: Bool = false) throws -> Path {
if overwrite, to.exists { if overwrite, to.exists {
try FileManager.default.removeItem(at: to.url) try FileManager.default.removeItem(at: to.url)
} }
@@ -50,8 +72,19 @@ public extension Path {
return to return to
} }
/**
Moves 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 destination filename.
- SeeAlso: move(into:overwrite:)
*/
@discardableResult @discardableResult
public func move(into: Path) throws -> Path { func move(into: Path) throws -> Path {
if !into.exists { if !into.exists {
try into.mkpath() try into.mkpath()
} else if !into.isDirectory { } else if !into.isDirectory {
@@ -62,17 +95,23 @@ public extension Path {
return rv return rv
} }
/// Deletes the path, recursively if a directory.
@inlinable @inlinable
public func delete() throws { func delete() throws {
try FileManager.default.removeItem(at: url) try FileManager.default.removeItem(at: url)
} }
/**
Creates an empty file at this path.
- Returns: `self` to allow chaining.
*/
@inlinable @inlinable
@discardableResult @discardableResult
func touch() throws -> Path { func touch() throws -> Path {
return try "".write(to: self) return try "".write(to: self)
} }
/// Helper due to Linux Swift being incomplete.
private func _foo(go: () throws -> Void) throws { private func _foo(go: () throws -> Void) throws {
#if !os(Linux) #if !os(Linux)
do { do {
@@ -92,41 +131,29 @@ public extension Path {
#endif #endif
} }
/**
Creates the directory at this path.
- Note: Does not create any intermediary directories.
- Returns: `self` to allow chaining.
*/
@discardableResult @discardableResult
public func mkdir() throws -> Path { func mkdir() throws -> Path {
try _foo { try _foo {
try FileManager.default.createDirectory(at: self.url, withIntermediateDirectories: false, attributes: nil) try FileManager.default.createDirectory(at: self.url, withIntermediateDirectories: false, attributes: nil)
} }
return self return self
} }
/**
Creates the directory at this path.
- Note: Creates any intermediary directories, if required.
- Returns: `self` to allow chaining.
*/
@discardableResult @discardableResult
public func mkpath() throws -> Path { func mkpath() throws -> Path {
try _foo { try _foo {
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil)
} }
return self 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

@@ -9,12 +9,14 @@ extension Path: LosslessStringConvertible {
} }
extension Path: CustomStringConvertible { extension Path: CustomStringConvertible {
/// Returns `Path.string`
public var description: String { public var description: String {
return string return string
} }
} }
extension Path: CustomDebugStringConvertible { extension Path: CustomDebugStringConvertible {
/// Returns eg. `Path(string: "/foo")`
public var debugDescription: String { public var debugDescription: String {
return "Path(string: \(string))" return "Path(string: \(string))"
} }

View File

@@ -1,24 +1,36 @@
import Foundation import Foundation
public extension Path { public extension Path {
/// same as the `ls` command is shallow /**
func ls() throws -> [Entry] { Same as the `ls -a` command is shallow
let relativePaths = try FileManager.default.contentsOfDirectory(atPath: string) - Parameter includeHiddenFiles: If `true`, hidden files are included in the results. Defaults to `true`.
func convert(relativePath: String) -> Entry { - Important: `includeHiddenFiles` does not work on Linux
let path = self/relativePath */
func ls(includeHiddenFiles: Bool = true) throws -> [Entry] {
var opts = FileManager.DirectoryEnumerationOptions()
#if !os(Linux)
if !includeHiddenFiles {
opts.insert(.skipsHiddenFiles)
}
#endif
let paths = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: opts)
func convert(url: URL) -> Entry? {
guard let path = Path(url.path) else { return nil }
return Entry(kind: path.isDirectory ? .directory : .file, path: path) return Entry(kind: path.isDirectory ? .directory : .file, path: path)
} }
return relativePaths.map(convert) return paths.compactMap(convert)
} }
} }
public extension Array where Element == Path.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] { var directories: [Path] {
return compactMap { return compactMap {
$0.kind == .directory ? $0.path : nil $0.kind == .directory ? $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] { func files(withExtension ext: String) -> [Path] {
return compactMap { return compactMap {
$0.kind == .file && $0.path.extension == ext ? $0.path : nil $0.kind == .file && $0.path.extension == ext ? $0.path : nil

View File

@@ -1,24 +1,39 @@
import Foundation import Foundation
public extension Path { public extension Path {
/// Returns true if the path represents an actual file that is also writable by the current user.
var isWritable: Bool { var isWritable: Bool {
return FileManager.default.isWritableFile(atPath: string) return FileManager.default.isWritableFile(atPath: string)
} }
/// Returns true if the path represents an actual file that is also readable by the current user.
var isReadable: Bool {
return FileManager.default.isReadableFile(atPath: string)
}
/// Returns true if the path represents an actual file that is also deletable by the current user.
var isDeletable: Bool {
return FileManager.default.isDeletableFile(atPath: string)
}
/// Returns true if the path represents an actual directory.
var isDirectory: Bool { var isDirectory: Bool {
var isDir: ObjCBool = false var isDir: ObjCBool = false
return FileManager.default.fileExists(atPath: string, isDirectory: &isDir) && isDir.boolValue return FileManager.default.fileExists(atPath: string, isDirectory: &isDir) && isDir.boolValue
} }
/// Returns true if the path represents an actual filesystem entry that is *not* a directory.
var isFile: Bool { var isFile: Bool {
var isDir: ObjCBool = true var isDir: ObjCBool = true
return FileManager.default.fileExists(atPath: string, isDirectory: &isDir) && !isDir.boolValue return FileManager.default.fileExists(atPath: string, isDirectory: &isDir) && !isDir.boolValue
} }
/// Returns true if the path represents an actual file that is also executable by the current user.
var isExecutable: Bool { var isExecutable: Bool {
return FileManager.default.isExecutableFile(atPath: string) return FileManager.default.isExecutableFile(atPath: string)
} }
/// Returns true if the path represents an actual filesystem entry.
var exists: Bool { var exists: Bool {
return FileManager.default.fileExists(atPath: string) return FileManager.default.fileExists(atPath: string)
} }

View File

@@ -1,35 +1,57 @@
import Foundation import Foundation
/**
Represents a platform filesystem absolute path.
The recommended conversions from string are:
let p1 = Path.root/pathString
let p2 = Path.root/url.path
let p3 = Path.cwd/relativePathString
let p4 = Path(userInput) ?? Path.cwd/userInput
- Note: There may not be an actual filesystem entry at the path.
*/
public struct Path: Equatable, Hashable, Comparable { public struct Path: Equatable, Hashable, Comparable {
/// The underlying filesystem path
public let string: String public let string: String
public static var cwd: Path { /**
return Path(string: FileManager.default.currentDirectoryPath) Returns the filename extension of this path.
} - Remark: Implemented via `NSString.pathExtension`.
*/
public static var root: Path {
return Path(string: "/")
}
public static var home: Path {
return Path(string: NSHomeDirectory())
}
@inlinable @inlinable
public var `extension`: String { public var `extension`: String {
return (string as NSString).pathExtension return (string as NSString).pathExtension
} }
/// - Note: always returns a valid path, `Path.root.parent` *is* `Path.root` /**
Returns the parent directory for this path.
Path is not aware of the nature of the underlying file, but this is
irrlevant since the operation is the same irrespective of this fact.
- Note: always returns a valid path, `Path.root.parent` *is* `Path.root`.
*/
public var parent: Path { public var parent: Path {
return Path(string: (string as NSString).deletingLastPathComponent) return Path(string: (string as NSString).deletingLastPathComponent)
} }
/// Returns a `URL` representing this file path.
@inlinable @inlinable
public var url: URL { public var url: URL {
return URL(fileURLWithPath: string) return URL(fileURLWithPath: string)
} }
/**
The basename for the provided file, optionally dropping the file extension.
Path.root.join("foo.swift").basename() // => "foo.swift"
Path.root.join("foo.swift").basename(dropExtension: true) // => "foo"
- 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 { public func basename(dropExtension: Bool = false) -> String {
let str = string as NSString let str = string as NSString
if !dropExtension { if !dropExtension {
@@ -44,7 +66,13 @@ public struct Path: Equatable, Hashable, Comparable {
} }
} }
//TODO another variant that returns `nil` if result would start with `..` /**
Returns a string representing the relative path to `base`.
- Note: If `base` is not a logical prefix for `self` your result will be prefixed some number of `../` components.
- 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 { public func relative(to base: Path) -> String {
// Split the two paths into their components. // Split the two paths into their components.
// FIXME: The is needs to be optimized to avoid unncessary copying. // FIXME: The is needs to be optimized to avoid unncessary copying.
@@ -75,27 +103,59 @@ public struct Path: Equatable, Hashable, Comparable {
} }
} }
public func join<S>(_ part: S) -> Path where S: StringProtocol { /**
Joins a path and a string to produce a new path.
Path.root.join("a") // => /a
Path.root.join("a/b") // => /a/b
Path.root.join("a").join("b") // => /a/b
Path.root.join("a").join("/b") // => /a/b
- Parameter pathComponent: The string to join with this path.
- Returns: A new joined path.
- SeeAlso: `/(_:, _:)`
*/
public func join<S>(_ pathComponent: S) -> Path where S: StringProtocol {
//TODO standardizingPath does more than we want really (eg tilde expansion) //TODO standardizingPath does more than we want really (eg tilde expansion)
let str = (string as NSString).appendingPathComponent(String(part)) let str = (string as NSString).appendingPathComponent(String(pathComponent))
return Path(string: (str as NSString).standardizingPath) return Path(string: (str as NSString).standardizingPath)
} }
/// Returns the locale-aware sort order for the two paths.
@inlinable @inlinable
public static func <(lhs: Path, rhs: Path) -> Bool { public static func <(lhs: Path, rhs: Path) -> Bool {
return lhs.string.compare(rhs.string, locale: .current) == .orderedAscending return lhs.string.compare(rhs.string, locale: .current) == .orderedAscending
} }
/// A file entry from a directory listing.
public struct Entry { public struct Entry {
/// The kind of this directory entry.
public enum Kind { public enum Kind {
/// The path is a file.
case file case file
/// The path is a directory.
case directory case directory
} }
/// The kind of this entry.
public let kind: Kind public let kind: Kind
/// The path of this entry.
public let path: Path public let path: Path
} }
} }
/**
Joins a path and a string to produce a new path.
Path.root/"a" // => /a
Path.root/"a/b" // => /a/b
Path.root/"a"/"b" // => /a/b
Path.root/"a"/"/b" // => /a/b
- Parameter lhs: The base path to join with `rhs`.
- Parameter rhs: The string to join with this `lhs`.
- Returns: A new joined path.
- SeeAlso: `Path.join(_:)`
*/
@inlinable @inlinable
public func /<S>(lhs: Path, rhs: S) -> Path where S: StringProtocol { public func /<S>(lhs: Path, rhs: S) -> Path where S: StringProtocol {
return lhs.join(rhs) return lhs.join(rhs)

View File

@@ -17,6 +17,7 @@ class PathTests: XCTestCase {
try tmpdir.join("a").mkdir().join("c").touch() try tmpdir.join("a").mkdir().join("c").touch()
try tmpdir.join("b").touch() try tmpdir.join("b").touch()
try tmpdir.join("c").touch() try tmpdir.join("c").touch()
try tmpdir.join(".d").mkdir().join("e").touch()
var paths = Set<String>() var paths = Set<String>()
var dirs = 0 var dirs = 0
@@ -26,8 +27,31 @@ class PathTests: XCTestCase {
} }
paths.insert(entry.path.basename()) paths.insert(entry.path.basename())
} }
XCTAssertEqual(dirs, 2)
XCTAssertEqual(paths, ["a", "b", "c", ".d"])
}
func testEnumerationSkippingHiddenFiles() throws {
#if !os(Linux)
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()
try tmpdir.join(".d").mkdir().join("e").touch()
var paths = Set<String>()
var dirs = 0
for entry in try tmpdir.ls(includeHiddenFiles: false) {
if entry.kind == .directory {
dirs += 1
}
paths.insert(entry.path.basename())
}
XCTAssertEqual(dirs, 1) XCTAssertEqual(dirs, 1)
XCTAssertEqual(paths, ["a", "b", "c"]) XCTAssertEqual(paths, ["a", "b", "c"])
#endif
} }
func testRelativeTo() { func testRelativeTo() {
@@ -93,4 +117,36 @@ class PathTests: XCTestCase {
decoder.userInfo[.relativePath] = root decoder.userInfo[.relativePath] = root
XCTAssertEqual(try decoder.decode([Path].self, from: data), input) XCTAssertEqual(try decoder.decode([Path].self, from: data), input)
} }
func testJoin() {
let prefix = Path.root/"Users/mxcl"
XCTAssertEqual(prefix/"b", Path("/Users/mxcl/b"))
XCTAssertEqual(prefix/"b"/"c", Path("/Users/mxcl/b/c"))
XCTAssertEqual(prefix/"b/c", Path("/Users/mxcl/b/c"))
XCTAssertEqual(prefix/"/b", Path("/Users/mxcl/b"))
let b = "b"
let c = "c"
XCTAssertEqual(prefix/b/c, Path("/Users/mxcl/b/c"))
XCTAssertEqual(Path.root/"~b", Path("/~b"))
XCTAssertEqual(Path.root/"~/b", Path("/~/b"))
XCTAssertEqual(Path("~/foo"), Path.home/"foo")
XCTAssertNil(Path("~foo"))
XCTAssertEqual(Path.root/"a/foo"/"../bar", Path.root/"a/bar")
XCTAssertEqual(Path.root/"a/foo"/"/../bar", Path.root/"a/bar")
XCTAssertEqual(Path.root/"a/foo"/"../../bar", Path.root/"bar")
XCTAssertEqual(Path.root/"a/foo"/"../../../bar", Path.root/"bar")
}
func testCopyInto() throws {
try Path.mktemp { root in
let bar = try root.join("bar").touch()
try Path.mktemp { root in
try root.join("bar").touch()
XCTAssertThrowsError(try bar.copy(into: root))
try bar.copy(into: root, overwrite: true)
}
}
}
} }

View File

@@ -1,8 +1,9 @@
@testable import Path
import Foundation import Foundation
public class TemporaryDirectory { class TemporaryDirectory {
public let url: URL let url: URL
public var path: Path { return Path(string: url.path) } var path: Path { return Path(string: url.path) }
/** /**
Creates a new temporary directory. Creates a new temporary directory.
@@ -19,7 +20,7 @@ public class TemporaryDirectory {
- Parameter appropriateFor: The temporary directory will be located on this - Parameter appropriateFor: The temporary directory will be located on this
volume. volume.
*/ */
public init(appropriateFor: URL? = nil) throws { init(appropriateFor: URL? = nil) throws {
#if !os(Linux) #if !os(Linux)
let appropriate: URL let appropriate: URL
if let appropriateFor = appropriateFor { if let appropriateFor = appropriateFor {
@@ -45,9 +46,26 @@ public class TemporaryDirectory {
} }
} }
public extension Path { extension Path {
static func mktemp<T>(body: (Path) throws -> T) throws -> T { static func mktemp<T>(body: (Path) throws -> T) throws -> T {
let tmp = try TemporaryDirectory() let tmp = try TemporaryDirectory()
return try body(tmp.path) return try body(tmp.path)
} }
} }
#if !os(macOS) && !os(Linux)
import XCTest
// SwiftPM generates code that is improperly escaped thus we require this to
// compile on iOS & tvOS.
public typealias XCTestCaseEntry = (testCaseClass: XCTestCase.Type, allTests: [(String, (XCTestCase) throws -> Void)])
public func testCase<T: XCTestCase>(_ allTests: [(String, (T) -> () throws -> Void)]) -> XCTestCaseEntry {
fatalError()
}
public func testCase<T: XCTestCase>(_ allTests: [(String, (T) -> () -> Void)]) -> XCTestCaseEntry {
fatalError()
}
#endif

View File

@@ -5,9 +5,12 @@ extension PathTests {
("testBasename", testBasename), ("testBasename", testBasename),
("testCodable", testCodable), ("testCodable", testCodable),
("testConcatenation", testConcatenation), ("testConcatenation", testConcatenation),
("testCopyInto", testCopyInto),
("testEnumeration", testEnumeration), ("testEnumeration", testEnumeration),
("testEnumerationSkippingHiddenFiles", testEnumerationSkippingHiddenFiles),
("testExists", testExists), ("testExists", testExists),
("testIsDirectory", testIsDirectory), ("testIsDirectory", testIsDirectory),
("testJoin", testJoin),
("testMkpathIfExists", testMkpathIfExists), ("testMkpathIfExists", testMkpathIfExists),
("testMktemp", testMktemp), ("testMktemp", testMktemp),
("testRelativePathCodable", testRelativePathCodable), ("testRelativePathCodable", testRelativePathCodable),
@@ -15,7 +18,7 @@ extension PathTests {
] ]
} }
#if os(Linux) #if !os(macOS)
public func __allTests() -> [XCTestCaseEntry] { public func __allTests() -> [XCTestCaseEntry] {
return [ return [
testCase(PathTests.__allTests), testCase(PathTests.__allTests),