Compare commits

...

48 Commits

Author SHA1 Message Date
repo-ranger[bot]
6c84754ad8 Merge pull request #33 from mxcl/bundle-private-frameworks
Bundle.privateFrameworks
2019-02-09 18:32:02 +00:00
Max Howell
8469565b06 Bundle.privateFrameworks 2019-02-09 13:24:57 -05:00
Max Howell
ed45d10179 Docs updates; CocoaPods 16.0 release; [ci skip] 2019-02-08 09:14:23 -05:00
Max Howell
8033ae49b4 Add [pathos]
[pathos]: https://github.com/dduan/Pathos

[skip ci]
2019-02-04 12:05:14 -05:00
Max Howell
b290173486 Update travis scripts 2019-02-04 12:04:48 -05:00
Max Howell
8248354a80 Some documentation improvements 2019-02-01 15:18:15 -05:00
Max Howell
14963e48f5 Merge pull request #30 from mxcl/codecov
More coverage
2019-02-01 10:26:59 -05:00
Max Howell
7f5340bc19 More coverage
Though I can hardly test these functions, at least we can verify they run
without crashing etc.
2019-02-01 10:17:37 -05:00
Max Howell
74074c634f Merge pull request #29 from mxcl/codecov
More coverage
2019-01-31 21:33:50 -05:00
Max Howell
74656bbfcd More coverage 2019-01-31 21:17:37 -05:00
Max Howell
cced2af2cd Merge pull request #28 from mxcl/codecov
More coverage
2019-01-31 19:17:43 -05:00
Max Howell
b9abd07318 More coverage 2019-01-31 19:09:36 -05:00
Max Howell
6b52932e7b Merge pull request #27 from mxcl/codecov
More coverage
2019-01-31 16:29:08 -05:00
Max Howell
c456081e65 More coverage 2019-01-31 14:26:09 -05:00
Max Howell
ec6c0113f9 Remaining code coverage
Can’t do coverage for Linux unfortunately.
2019-01-31 13:11:50 -05:00
Max Howell
7970c4d8a7 Document robustness & XP
[ci skip]
2019-01-31 12:38:41 -05:00
Max Howell
e342da8644 Remove auto-tagging since I can do this manually 2019-01-31 12:28:16 -05:00
Max Howell
9aa91c649e Merge pull request #26 from mxcl/codecov
Increase code coverage
2019-01-31 12:25:41 -05:00
Max Howell
62ea0d47b3 Fixes & increased code coverage 2019-01-31 12:15:31 -05:00
repo-ranger[bot]
ab9a70e947 Merge pull request #25 from mxcl/rename
Refactor rename -> rename(to:)
2019-01-31 15:32:59 +00:00
Max Howell
49ef073e34 Refactor rename -> rename(to:) 2019-01-31 10:15:39 -05:00
Max Howell
889d825b3a Merge pull request #24 from mxcl/rename
Rename
2019-01-31 10:08:02 -05:00
Max Howell
f1cd06fdff Add CI post success hook yamls 2019-01-31 09:58:46 -05:00
Max Howell
c6e840b9b6 Add rename 2019-01-31 08:39:54 -05:00
Max Howell
eb34ac4af8 Add overwrite parameter to move(into:) 2019-01-31 08:37:32 -05:00
Max Howell
66ae86c986 Enable codecov.io 2019-01-31 08:37:14 -05:00
Max Howell
c432f710eb Merge pull request #22 from mxcl/files()
Entry.files defaults to all files
2019-01-28 12:17:08 -05:00
Max Howell
19c0c19bb6 Entry.files defaults to all files 2019-01-28 12:04:47 -05:00
Max Howell
ee1f46954c Fixes #20
[skip ci]
2019-01-28 11:05:51 -05:00
Max Howell
2394cc1c85 Merge pull request #19 from JaapWijnen/filehandle-extensions
added extension to initialize filehandle from path
2019-01-27 16:59:44 -05:00
Jaap Wijnen
50bb319619 added extension to initialize filehandle from path 2019-01-27 22:57:19 +01:00
Max Howell
9f40068833 Badge for Swift 5 support 2019-01-26 16:15:45 -05:00
Max Howell
67f4e5f41a Merge pull request #16 from mxcl/bundle-non-optional
Bundle extensions don’t return optional Paths
2019-01-26 15:46:03 -05:00
Max Howell
83c83dcaba Bundle extensions don’t return optional Paths
Rationale: Paths are not guaranteed to exist, the Bundle functions return optional if the path doesn't exist. So we'll provide a sensible default instead and you need to check the result exists at some point instead.

This makes more elegant chains, the chain will fail when you operate on it, but you don’t have to do a check for optional first. Or risk a bang.
2019-01-26 15:20:32 -05:00
Max Howell
93e2701950 Docs tweaks
[ci skip]
2019-01-26 15:10:52 -05:00
Max Howell
bbf1f24ef6 Fix Cocoapods deploy 2019-01-26 14:42:00 -05:00
Max Howell
c08ccdfb30 Merge pull request #15 from mxcl/dynamic-members
Dynamic members
2019-01-26 13:34:17 -05:00
Max Howell
859164e59f Dynamic Members 2019-01-26 13:23:25 -05:00
Max Howell
44be1c45a9 Add Path.ctime 2019-01-26 13:17:39 -05:00
Max Howell
99b948f9c1 Minor documentation fixes
[ci skip]
2019-01-26 13:17:39 -05:00
Max Howell
3beba13677 Merge pull request #14 from mxcl/delete-noop
Delete noop
2019-01-26 11:12:17 -05:00
Max Howell
bafb05ff54 Document noop behavior 2019-01-26 11:05:49 -05:00
Max Howell
356a1b3ac2 Delete is a noop if file doesn’t exist
Closes #11.
2019-01-26 11:05:31 -05:00
Max Howell
6d8712f4d6 Remove mkpath, add mkdir(.p) 2019-01-26 11:05:10 -05:00
Max Howell
8744b68709 Make nodoc work for Codable 2019-01-26 10:59:20 -05:00
Max Howell
9ea32048f7 Merge pull request #13 from mxcl/more-docs
Improved documentation; Fixes #12
2019-01-25 21:20:24 -05:00
Max Howell
4b16dac3bf Improved documentation; Fixes #12 2019-01-25 20:46:37 -05:00
Max Howell
b613449232 Making this static will fix documentation location
[ci skip]
2019-01-25 12:23:54 -05:00
17 changed files with 880 additions and 274 deletions

2
.github/codecov.yml vendored Normal file
View File

@@ -0,0 +1,2 @@
ignore:
- Tests

2
.github/ranger.yml vendored Normal file
View File

@@ -0,0 +1,2 @@
merges:
- action: delete_branch

2
.gitignore vendored
View File

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

View File

@@ -19,9 +19,10 @@ jobs:
name: macOS / Swift 4.2.1 name: macOS / Swift 4.2.1
- &xcodebuild - &xcodebuild
before_install: swift package generate-xcodeproj before_install: swift package generate-xcodeproj --enable-code-coverage
xcode_destination: platform=iOS Simulator,OS=latest,name=iPhone XS xcode_destination: platform=iOS Simulator,OS=latest,name=iPhone XS
name: iOS / Swift 4.2.1 name: iOS / Swift 4.2.1
after_success: bash <(curl -s https://codecov.io/bash)
- <<: *xcodebuild - <<: *xcodebuild
xcode_destination: platform=tvOS Simulator,OS=latest,name=Apple TV xcode_destination: platform=tvOS Simulator,OS=latest,name=Apple TV
name: tvOS / Swift 4.2.1 name: tvOS / Swift 4.2.1
@@ -34,6 +35,7 @@ jobs:
-scheme Path.swift-Package \ -scheme Path.swift-Package \
-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
after_success: false
- &linux - &linux
env: SWIFT_VERSION=4.2.1 env: SWIFT_VERSION=4.2.1
@@ -69,6 +71,8 @@ jobs:
- UseModernBuildSystem=NO - UseModernBuildSystem=NO
output: output output: output
github_url: https://github.com/mxcl/Path.swift github_url: https://github.com/mxcl/Path.swift
exclude:
- Sources/Path+StringConvertibles.swift
EOF EOF
sed -i '' "s/TRAVIS_TAG/$TRAVIS_TAG/" .jazzy.yaml sed -i '' "s/TRAVIS_TAG/$TRAVIS_TAG/" .jazzy.yaml
# ^^ this weirdness because Travis multiline YAML is broken and inserts # ^^ this weirdness because Travis multiline YAML is broken and inserts
@@ -86,16 +90,36 @@ jobs:
tags: true tags: true
- name: CocoaPods - name: CocoaPods
before_install: | before_install: export TRAVIS_REPO_NAME=${TRAVIS_REPO_SLUG#*/}
cat <<\ \ EOF> Path.swift.podspec install: gem install cocoapods
before_script: |
export DESCRIPTION=$(swift - <<\ \ EOF
import Foundation
struct Response: Decodable { let description: String }
let token = ProcessInfo.processInfo.environment["GITHUB_TOKEN"]!
let slug = ProcessInfo.processInfo.environment["TRAVIS_REPO_SLUG"]!
let url = URL(string: "https://api.github.com/repos/\(slug)")!
var rq = URLRequest(url: url)
rq.setValue("token \(token)", forHTTPHeaderField: "Authorization")
let semaphore = DispatchSemaphore(value: 0)
var data: Data!
URLSession.shared.dataTask(with: rq) { d, _, _ in
data = d
semaphore.signal()
}.resume()
semaphore.wait()
let rsp = try JSONDecoder().decode(Response.self, from: data)
print(rsp.description, terminator: "")
EOF)
cat <<\ \ EOF> $TRAVIS_REPO_NAME.podspec
Pod::Spec.new do |s| Pod::Spec.new do |s|
s.name = 'Path.swift' s.name = ENV['TRAVIS_REPO_NAME']
s.version = 'TRAVIS_TAG' s.version = ENV['TRAVIS_TAG']
s.summary = 'Delightful, robust file-pathing functions' s.summary = ENV['DESCRIPTION']
s.homepage = 'https://github.com/mxcl/Path.swift' s.homepage = "https://github.com/#{ENV['TRAVIS_REPO_SLUG']}"
s.license = { :type => 'Unlicense', :file => 'LICENSE.md' } s.license = { type: 'Unlicense', file: 'LICENSE.md' }
s.author = { 'mxcl' => 'mxcl@me.com' } s.author = { mxcl: 'mxcl@me.com' }
s.source = { :git => 'https://github.com/mxcl/Path.swift.git', :tag => s.version.to_s } s.source = { git: "https://github.com/#{ENV['TRAVIS_REPO_SLUG']}.git", tag: s.version }
s.social_media_url = 'https://twitter.com/mxcl' s.social_media_url = 'https://twitter.com/mxcl'
s.osx.deployment_target = '10.10' s.osx.deployment_target = '10.10'
s.ios.deployment_target = '8.0' s.ios.deployment_target = '8.0'
@@ -105,7 +129,4 @@ jobs:
s.swift_version = '4.2' s.swift_version = '4.2'
end end
EOF 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 script: pod trunk push

View File

@@ -1,7 +1,7 @@
# Path.swift ![badge-platforms] ![badge-languages] [![Build Status](https://travis-ci.com/mxcl/Path.swift.svg)](https://travis-ci.com/mxcl/Path.swift) # Path.swift ![badge-platforms][] ![badge-languages][] [![badge-ci][]][travis] [![badge-jazzy][]][docs] [![badge-codecov][]][codecov] [![badge-version][]][cocoapods]
A file-system pathing library focused on developer experience and robust A file-system pathing library focused on developer experience and robust end
endresults. results.
```swift ```swift
import Path import Path
@@ -28,12 +28,15 @@ print(bar) // => /bar
print(bar.isFile) // => true print(bar.isFile) // => true
// careful API considerations so as to avoid common bugs // careful API considerations so as to avoid common bugs
let foo = try Path.root.join("foo").copy(into: Path.root.mkdir("bar")) let foo = try Path.root.join("foo").copy(into: Path.root.join("bar").mkdir())
print(foo) // => /bar/foo print(foo) // => /bar/foo
print(foo.isFile) // => true print(foo.isFile) // => true
// A practical example: installing a helper executable // we support dynamic members (_use_sparingly_):
try Bundle.resources.join("helper").copy(into: Path.home.join(".local/bin").mkpath()).chmod(0o500) let prefs = Path.home.Library.Preferences // => /Users/mxcl/Library/Preferences
// a practical example: installing a helper executable
try Bundle.resources.helper.copy(into: Path.root.usr.local.bin).chmod(0o500)
``` ```
We emphasize safety and correctness, just like Swift, and also (again like We emphasize safety and correctness, just like Swift, and also (again like
@@ -42,8 +45,9 @@ Swift), we provide a thoughtful and comprehensive (yet concise) API.
# Support mxcl # Support mxcl
Hi, Im Max Howell and I have written a lot of open source software, and 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 probably you already use some of it (Homebrew anyone?). I work full-time on
can continue to make tools and software you need and love. I appreciate it x. open source and its hard; currently I earn *less* than minimum wage. Please
help me continue my work, I appreciate it x
<a href="https://www.patreon.com/mxcl"> <a href="https://www.patreon.com/mxcl">
<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">
@@ -53,7 +57,8 @@ can continue to make tools and software you need and love. I appreciate it x.
# Handbook # Handbook
Our [online API documentation] is automatically updated for new releases. Our [online API documentation][docs] covers 100% of our public API and is
automatically updated for new releases.
## Codable ## Codable
@@ -94,6 +99,19 @@ decoder.userInfo[.relativePath] = Path.home
decoder.decode(from: data) decoder.decode(from: data)
``` ```
## Dynamic members
We support `@dynamicMemberLookup`:
```swift
let ls = Path.root.usr.bin.ls // => /usr/bin/ls
```
This is less commonly useful than you would think, hence our documentation
does not use it. Usually you are joining variables or other `String` arguments
or trying to describe files (and files usually have extensions). However when
you need it, its *lovely*.
## Initializing from user-input ## Initializing from user-input
The `Path` initializer returns `nil` unless fed an absolute path; thus to The `Path` initializer returns `nil` unless fed an absolute path; thus to
@@ -107,10 +125,8 @@ 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`, Our initializer is nameless to be consistent with the equivalent operation for
the same conformance as that `Int`, `Float` etc. conform. The protocol enforces converting strings to `Int`, `Float` etc. in the standard library.
a nameless initialization and since it is appropriate for us to conform to it,
we do.
## Extensions ## Extensions
@@ -124,8 +140,7 @@ bashProfile += "\n\nfoo"
try bashProfile.write(to: Path.home/".bash_profile") try bashProfile.write(to: Path.home/".bash_profile")
try Bundle.main.resources!.join("foo").copy(to: .home) try Bundle.main.resources.join("foo").copy(to: .home)
// ^^ `-> Path?` because the underlying `Bundle` function is `-> String?`
``` ```
## Directory listings ## Directory listings
@@ -147,13 +162,27 @@ for entry in Path.home.ls() where entry.path.mtime > yesterday {
// //
} }
let dirs = Path.home.ls().directories().filter { let dirs = Path.home.ls().directories
//
} let files = Path.home.ls().files
let swiftFiles = Path.home.ls().files(withExtension: "swift") let swiftFiles = Path.home.ls().files(withExtension: "swift")
``` ```
# `Path.swift` is robust
Some parts of `FileManager` are not exactly idiomatic. For example
`isExecutableFile` returns `true` even if there is no file there, it is instead
telling you that *if* you made a file there it *could* be executable. Thus we
check the POSIX permissions of the file first, before returning the result of
`isExecutableFile`. `Path.swift` has done the leg-work for you so you can get on
with your work without worries.
# `Path.swift` is properly cross-platform
`FileManager` on Linux is full of holes. We have found the holes and worked
round them where necessary.
# Rules & Caveats # Rules & Caveats
Paths are just string representations, there *might not* be a real file there. Paths are just string representations, there *might not* be a real file there.
@@ -186,18 +215,28 @@ Path("~/foo")! // => /Users/mxcl/foo
Path("~foo") // => nil Path("~foo") // => nil
``` ```
*Path.swift* has the general policy that if the desired end result preexists,
then its a noop:
* If you try to delete a file, but the file doesn't exist, we do nothing.
* If you try to make a directory and it already exists, we do nothing.
However notably if you try to copy or move a file with specifying `overwrite`
and the file already exists at the destination and is identical, we dont check
for that as the check was deemed too expensive to be worthwhile.
# Installation # Installation
SwiftPM: SwiftPM:
```swift ```swift
package.append(.package(url: "https://github.com/mxcl/Path.swift", from: "0.5.0")) package.append(.package(url: "https://github.com/mxcl/Path.swift.git", from: "0.5.0"))
``` ```
CocoaPods: CocoaPods:
```ruby ```ruby
pod 'Path.swift' ~> '0.5.0' pod 'Path.swift', '~> 0.5'
``` ```
Carthage: Carthage:
@@ -215,11 +254,19 @@ https://codebasesaga.com/canopy/
# Alternatives # Alternatives
* [Pathos](https://github.com/dduan/Pathos) by Daniel Duan
* [PathKit](https://github.com/kylef/PathKit) by Kyle Fuller * [PathKit](https://github.com/kylef/PathKit) by Kyle Fuller
* [Files](https://github.com/JohnSundell/Files) by John Sundell * [Files](https://github.com/JohnSundell/Files) by John Sundell
* [Utility](https://github.com/apple/swift-package-manager) by Apple * [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%20%7C%205.0-orange.svg
[online API documentation]: https://mxcl.github.io/Path.swift/Structs/Path.html [docs]: https://mxcl.github.io/Path.swift/Structs/Path.html
[badge-jazzy]: https://raw.githubusercontent.com/mxcl/Path.swift/gh-pages/badge.svg?sanitize=true
[badge-codecov]: https://codecov.io/gh/mxcl/Path.swift/branch/master/graph/badge.svg
[badge-ci]: https://travis-ci.com/mxcl/Path.swift.svg
[travis]: https://travis-ci.com/mxcl/Path.swift
[codecov]: https://codecov.io/gh/mxcl/Path.swift
[badge-version]: https://img.shields.io/cocoapods/v/Path.swift.svg?label=version
[cocoapods]: https://cocoapods.org/pods/Path.swift

View File

@@ -9,14 +9,25 @@ public extension Bundle {
return str.flatMap(Path.init) return str.flatMap(Path.init)
} }
/// Returns the path for the shared-frameworks directory in this bundle. /**
var sharedFrameworks: Path? { Returns the path for the shared-frameworks directory in this bundle.
return sharedFrameworksPath.flatMap(Path.init) - Note: This is typically `ShareFrameworks`
*/
var sharedFrameworks: Path {
return sharedFrameworksPath.flatMap(Path.init) ?? defaultSharedFrameworksPath
}
/**
Returns the path for the private-frameworks directory in this bundle.
- Note: This is typically `Frameworks`
*/
var privateFrameworks: Path {
return privateFrameworksPath.flatMap(Path.init) ?? defaultSharedFrameworksPath
} }
/// Returns the path for the resources directory in this bundle. /// Returns the path for the resources directory in this bundle.
var resources: Path? { var resources: Path {
return resourcePath.flatMap(Path.init) return resourcePath.flatMap(Path.init) ?? defaultResourcesPath
} }
/// Returns the path for this bundle. /// Returns the path for this bundle.
@@ -68,3 +79,46 @@ public extension Data {
return to return to
} }
} }
/// Extensions on `FileHandle` that work with `Path` rather than `String` or `URL`
public extension FileHandle {
/// Initializes this `FileHandle` for reading at the location of the provided path.
@inlinable
convenience init(forReadingAt path: Path) throws {
try self.init(forReadingFrom: path.url)
}
/// Initializes this `FileHandle` for writing at the location of the provided path.
@inlinable
convenience init(forWritingAt path: Path) throws {
try self.init(forWritingTo: path.url)
}
/// Initializes this `FileHandle` for reading and writing at the location of the provided path.
@inlinable
convenience init(forUpdatingAt path: Path) throws {
try self.init(forUpdating: path.url)
}
}
internal extension Bundle {
var defaultSharedFrameworksPath: Path {
#if os(macOS)
return path.join("Contents/Frameworks")
#elseif os(Linux)
return path.join("lib")
#else
return path.join("Frameworks")
#endif
}
var defaultResourcesPath: Path {
#if os(macOS)
return path.join("Contents/Resources")
#elseif os(Linux)
return path.join("share")
#else
return path
#endif
}
}

View File

@@ -1,35 +1,33 @@
import Foundation import Foundation
public extension Path { public extension Path {
/// - Note: If file is already locked, does nothing //MARK: Filesystem Attributes
/// - Note: If file doesnt exist, throws
@discardableResult /**
func lock() throws -> Path { Returns the creation-time of the file.
var attrs = try FileManager.default.attributesOfItem(atPath: string) - Note: Returns `nil` if there is no creation-time, this should only happen if the file doesnt exist.
let b = attrs[.immutable] as? Bool ?? false - Important: On Linux this is filesystem dependendent and may not exist.
if !b { */
attrs[.immutable] = true var ctime: Date? {
try FileManager.default.setAttributes(attrs, ofItemAtPath: string) do {
let attrs = try FileManager.default.attributesOfItem(atPath: string)
return attrs[.creationDate] as? Date
} catch {
return nil
} }
return self
} }
/// - Note: If file isnt locked, does nothing /**
/// - Note: If file doesnt exist, does nothing Returns the modification-time of the file.
@discardableResult - Note: If this returns `nil` and the file exists, something is very wrong.
func unlock() throws -> Path { */
var attrs: [FileAttributeKey: Any] var mtime: Date? {
do { do {
attrs = try FileManager.default.attributesOfItem(atPath: string) let attrs = try FileManager.default.attributesOfItem(atPath: string)
} catch CocoaError.fileReadNoSuchFile { return attrs[.modificationDate] as? Date
return self } catch {
return nil
} }
let b = attrs[.immutable] as? Bool ?? false
if b {
attrs[.immutable] = false
try FileManager.default.setAttributes(attrs, ofItemAtPath: string)
}
return self
} }
/** /**
@@ -42,19 +40,47 @@ public extension Path {
try FileManager.default.setAttributes([.posixPermissions: octal], ofItemAtPath: string) try FileManager.default.setAttributes([.posixPermissions: octal], ofItemAtPath: string)
return self return self
} }
/**
Applies the macOS filesystem lock attribute.
- Note: If file is already locked, does nothing.
- Note: If file doesnt exist, throws.
- Important: On Linux does nothing.
*/
@discardableResult
func lock() throws -> Path {
#if !os(Linux)
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)
}
#endif
return self
}
/** /**
Returns the modification-time. - Note: If file isnt locked, does nothing.
- Note: Returns the creation time if there is no modification time. - Note: If file doesnt exist, does nothing.
- Note: Returns UNIX-time-zero if neither are available, though this *should* be impossible. - Important: On Linux does nothing.
- SeeAlso: `lock()`
*/ */
var mtime: Date { @discardableResult
func unlock() throws -> Path {
#if !os(Linux)
var attrs: [FileAttributeKey: Any]
do { do {
let attrs = try FileManager.default.attributesOfItem(atPath: string) attrs = try FileManager.default.attributesOfItem(atPath: string)
return attrs[.modificationDate] as? Date ?? attrs[.creationDate] as? Date ?? Date(timeIntervalSince1970: 0) } catch CocoaError.fileReadNoSuchFile {
} catch { return self
//TODO log error
return Date(timeIntervalSince1970: 0)
} }
let b = attrs[.immutable] as? Bool ?? false
if b {
attrs[.immutable] = false
try FileManager.default.setAttributes(attrs, ofItemAtPath: string)
}
#endif
return self
} }
} }

View File

@@ -1,13 +1,31 @@
import Foundation import Foundation
/// Provided for relative-path coding. See the instructions in our `README`. /**
Provided for relative-path coding. See the instructions in our
[README](https://github.com/mxcl/Path.swift/#codable).
*/
public extension CodingUserInfoKey { public extension CodingUserInfoKey {
/// If set paths are encoded as relative to this path. /**
If set on an `Encoder`s `userInfo` all paths are encoded relative to this path.
For example:
let encoder = JSONEncoder()
encoder.userInfo[.relativePath] = Path.home
encoder.encode([Path.home, Path.home/"foo"])
- Remark: See the [README](https://github.com/mxcl/Path.swift/#codable) for more information.
*/
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 { Provided for relative-path coding. See the instructions in our
[README](https://github.com/mxcl/Path.swift/#codable).
*/
extension Path: Codable {
/// - SeeAlso: `CodingUserInfoKey.relativePath`
/// :nodoc:
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)
if value.hasPrefix("/") { if value.hasPrefix("/") {
@@ -20,6 +38,8 @@ extension Path: Codable {
} }
} }
/// - SeeAlso: `CodingUserInfoKey.relativePath`
/// :nodoc:
public func encode(to encoder: Encoder) throws { public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer() var container = encoder.singleValueContainer()
if let root = encoder.userInfo[.relativePath] as? Path { if let root = encoder.userInfo[.relativePath] as? Path {

View File

@@ -1,7 +1,9 @@
import Foundation import Foundation
extension Path { extension Path {
/// Returns a `Path` containing ``FileManager.default.currentDirectoryPath`. //MARK: Common Directories
/// Returns a `Path` containing `FileManager.default.currentDirectoryPath`.
public static var cwd: Path { public static var cwd: Path {
return Path(string: FileManager.default.currentDirectoryPath) return Path(string: FileManager.default.currentDirectoryPath)
} }
@@ -45,18 +47,7 @@ extension Path {
fatalError() fatalError()
} }
#else #else
guard let pathString = FileManager.default.urls(for: searchPath, in: .userDomainMask).first?.path else { guard let pathString = FileManager.default.urls(for: searchPath, in: .userDomainMask).first?.path else { return defaultUrl(for: searchPath) }
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) return Path(string: pathString)
#endif #endif
} }
@@ -72,7 +63,7 @@ extension Path {
/** /**
The root for cache files. The root for cache files.
- Note: On Linux this is 'XDG_CACHE_HOME'. - Note: On Linux this is `XDG_CACHE_HOME`.
- Note: You should create a subdirectory before creating any files. - Note: You should create a subdirectory before creating any files.
*/ */
public static var caches: Path { public static var caches: Path {
@@ -88,3 +79,19 @@ extension Path {
return path(for: .applicationSupportDirectory) return path(for: .applicationSupportDirectory)
} }
} }
#if !os(Linux)
func defaultUrl(for searchPath: FileManager.SearchPathDirectory) -> Path {
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()
}
}
#endif

View File

@@ -1,12 +1,25 @@
import Foundation import Foundation
#if os(Linux)
import Glibc
#endif
public extension Path { public extension Path {
//MARK: File Management
/** /**
Copies a file. Copies a file.
try Path.root.join("bar").copy(to: Path.home/"foo")
// => "/Users/mxcl/foo"
- 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` and both `self` and `to` are files, overwrites `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. - Note: If either `self` or `to are directories, `overwrite` is ignored.
- Note: Throws if `overwrite` is `false` yet `to` is *already* identical to
`self` because even though *Path.swifts* policy is to noop if the desired
end result preexists, checking for this condition is too expensive a
trade-off.
- Returns: `to` to allow chaining - Returns: `to` to allow chaining
- SeeAlso: `copy(into:overwrite:)` - SeeAlso: `copy(into:overwrite:)`
*/ */
@@ -15,6 +28,11 @@ public extension Path {
if overwrite, to.isFile, isFile { if overwrite, to.isFile, isFile {
try FileManager.default.removeItem(at: to.url) try FileManager.default.removeItem(at: to.url)
} }
#if os(Linux) && !swift(>=5.1) // check if fixed
if !overwrite, to.isFile {
throw CocoaError.error(.fileWriteFileExists)
}
#endif
try FileManager.default.copyItem(atPath: string, toPath: to.string) try FileManager.default.copyItem(atPath: string, toPath: to.string)
return to return to
} }
@@ -22,34 +40,38 @@ public extension Path {
/** /**
Copies a file into a directory Copies a file into a directory
If the destination does not exist, this function creates the directory first. try Path.root.join("bar").copy(into: .home)
// => "/Users/mxcl/bar"
// 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) // 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").mkdir(.p)).chmod(0o500)
If the destination does not exist, this function creates the directory
(including intermediary directories if necessary) first.
- 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:)` - Note: `throws` if `into` is a file.
- Note: Throws if `overwrite` is `false` yet `to` is *already* identical to
`self` because even though *Path.swifts* policy is to noop if the desired
end result preexists, checking for this condition is too expensive a
trade-off.
- SeeAlso: `copy(to:overwrite:)`
*/ */
@discardableResult @discardableResult
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 into.mkdir(.p)
} }
let rv = into/basename() let rv = into/basename()
if overwrite, rv.isFile { if overwrite, rv.isFile {
try rv.delete() try rv.delete()
} }
#if os(Linux) #if os(Linux) && !swift(>=5.1) // check if fixed
#if swift(>=5.1)
// check if fixed
#else
if !overwrite, rv.isFile { if !overwrite, rv.isFile {
throw CocoaError.error(.fileWriteFileExists) throw CocoaError.error(.fileWriteFileExists)
} }
#endif
#endif #endif
try FileManager.default.copyItem(at: url, to: rv.url) try FileManager.default.copyItem(at: url, to: rv.url)
return rv return rv
@@ -57,15 +79,23 @@ public extension Path {
/** /**
Moves a file. Moves a file.
- Note: `throws` if `to` is a directory.
try Path.root.join("bar").move(to: Path.home/"foo")
// => "/Users/mxcl/foo"
- Parameter to: Destination filename. - Parameter to: Destination filename.
- Parameter overwrite: If true overwrites any file that already exists at `to`. - Parameter overwrite: If true overwrites any file that already exists at `to`.
- Returns: `to` to allow chaining - Returns: `to` to allow chaining
- SeeAlso: move(into:overwrite:) - Note: `throws` if `to` is a directory.
- Note: Throws if `overwrite` is `false` yet `to` is *already* identical to
`self` because even though *Path.swifts* policy is to noop if the desired
end result preexists, checking for this condition is too expensive a
trade-off.
- SeeAlso: `move(into:overwrite:)`
*/ */
@discardableResult @discardableResult
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.isFile {
try FileManager.default.removeItem(at: to.url) try FileManager.default.removeItem(at: to.url)
} }
try FileManager.default.moveItem(at: url, to: to.url) try FileManager.default.moveItem(at: url, to: to.url)
@@ -75,85 +105,116 @@ public extension Path {
/** /**
Moves a file into a directory Moves a file into a directory
If the destination does not exist, this function creates the directory first. try Path.root.join("bar").move(into: .home)
// => "/Users/mxcl/bar"
If the destination does not exist, this function creates the directory
(including intermediary directories if necessary) first.
- 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`.
- Note: `throws` if `into` is a file.
- Returns: The `Path` of destination filename. - Returns: The `Path` of destination filename.
- SeeAlso: move(into:overwrite:) - SeeAlso: `move(to:overwrite:)`
*/ */
@discardableResult @discardableResult
func move(into: Path) throws -> Path { func move(into: Path, overwrite: Bool = false) throws -> Path {
if !into.exists { if !into.exists {
try into.mkpath() try into.mkdir(.p)
} else if !into.isDirectory { } else if !into.isDirectory {
throw CocoaError.error(.fileWriteFileExists) throw CocoaError.error(.fileWriteFileExists)
} }
let rv = into/basename() let rv = into/basename()
if overwrite, rv.isFile {
try FileManager.default.removeItem(at: rv.url)
}
try FileManager.default.moveItem(at: url, to: rv.url) try FileManager.default.moveItem(at: url, to: rv.url)
return rv return rv
} }
/// Deletes the path, recursively if a directory. /**
Deletes the path, recursively if a directory.
- Note: noop: if the path doesnt exist
*Path.swift* doesnt error if desired end result preexists.
- Note: On UNIX will this function will succeed if the parent directory is writable and the current user has permission.
- Note: This function will fail if the file or directory is locked
- SeeAlso: `lock()`
*/
@inlinable @inlinable
func delete() throws { func delete() throws {
try FileManager.default.removeItem(at: url) if exists {
try FileManager.default.removeItem(at: url)
}
} }
/** /**
Creates an empty file at this path. Creates an empty file at this path or if the file exists, updates its modification time.
- Returns: `self` to allow chaining. - Returns: `self` to allow chaining.
*/ */
@inlinable @inlinable
@discardableResult @discardableResult
func touch() throws -> Path { func touch() throws -> Path {
return try "".write(to: self) if !exists {
guard FileManager.default.createFile(atPath: string, contents: nil) else {
throw CocoaError.error(.fileWriteUnknown)
}
} else {
#if os(Linux)
let fd = open(string, O_WRONLY)
defer { close(fd) }
futimens(fd, nil)
#else
try FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: string)
#endif
}
return self
} }
/// Helper due to Linux Swift being incomplete. /**
private func _foo(go: () throws -> Void) throws { Creates the directory at this path.
#if !os(Linux) - Parameter options: Specify `mkdir(.p)` to create intermediary directories.
- Note: We do not error if the directory already exists (even without `.p`)
because *Path.swift* noops if the desired end result preexists.
- Returns: `self` to allow chaining.
*/
@discardableResult
func mkdir(_ options: MakeDirectoryOptions? = nil) throws -> Path {
do { do {
try go() let wid = options == .p
try FileManager.default.createDirectory(at: self.url, withIntermediateDirectories: wid, attributes: nil)
} catch CocoaError.Code.fileWriteFileExists { } catch CocoaError.Code.fileWriteFileExists {
// noop //noop (fails to trigger on Linux)
}
#else
do {
try go()
} catch { } catch {
#if os(Linux)
let error = error as NSError let error = error as NSError
guard error.domain == NSCocoaErrorDomain, error.code == CocoaError.Code.fileWriteFileExists.rawValue else { guard error.domain == NSCocoaErrorDomain, error.code == CocoaError.Code.fileWriteFileExists.rawValue else {
throw error throw error
} }
} #else
#endif throw error
} #endif
/**
Creates the directory at this path.
- Note: Does not create any intermediary directories.
- Returns: `self` to allow chaining.
*/
@discardableResult
func mkdir() throws -> Path {
try _foo {
try FileManager.default.createDirectory(at: self.url, withIntermediateDirectories: false, attributes: nil)
} }
return self return self
} }
/** /**
Creates the directory at this path. Renames the file at path.
- Note: Creates any intermediary directories, if required.
- Returns: `self` to allow chaining. Path.root.foo.bar.rename(to: "baz") // => /foo/baz
- Parameter to: the new basename for the file
- Returns: The renamed path.
*/ */
@discardableResult @discardableResult
func mkpath() throws -> Path { func rename(to newname: String) throws -> Path {
try _foo { let newpath = parent/newname
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) try FileManager.default.moveItem(atPath: string, toPath: newpath.string)
} return newpath
return self
} }
} }
/// Options for `Path.mkdir(_:)`
public enum MakeDirectoryOptions {
/// Creates intermediary directories; works the same as `mkdir -p`.
case p
}

View File

@@ -1,14 +1,6 @@
import class Foundation.NSString import class Foundation.NSString
extension Path: LosslessStringConvertible { extension Path: CustomStringConvertible {
/// 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 {
/// Returns `Path.string` /// Returns `Path.string`
public var description: String { public var description: String {
return string return string
@@ -18,6 +10,6 @@ extension Path: CustomStringConvertible {
extension Path: CustomDebugStringConvertible { extension Path: CustomDebugStringConvertible {
/// Returns eg. `Path(string: "/foo")` /// Returns eg. `Path(string: "/foo")`
public var debugDescription: String { public var debugDescription: String {
return "Path(string: \(string))" return "Path(\(string))"
} }
} }

View File

@@ -1,8 +1,28 @@
import Foundation import Foundation
public extension Path { /**
A file entry from a directory listing.
- SeeAlso: `ls()`
*/
public struct Entry {
/// The kind of this directory entry.
public enum Kind {
/// The path is a file.
case file
/// The path is a directory.
case directory
}
/// The kind of this entry.
public let kind: Kind
/// The path of this entry.
public let path: Path
}
public extension Path {
//MARK: Directory Listings
/** /**
Same as the `ls -a` command is shallow Same as the `ls -a` command output is shallow and unsorted.
- Parameter includeHiddenFiles: If `true`, hidden files are included in the results. Defaults to `true`. - Parameter includeHiddenFiles: If `true`, hidden files are included in the results. Defaults to `true`.
- Important: `includeHiddenFiles` does not work on Linux - Important: `includeHiddenFiles` does not work on Linux
*/ */
@@ -22,7 +42,8 @@ public extension Path {
} }
} }
public extension Array where Element == Path.Entry { /// Convenience functions for the array return value of `Path.ls()`
public extension Array where Element == Entry {
/// Filters the list of entries to be a list of Paths that are directories. /// Filters the list of entries to be a list of Paths that are directories.
var directories: [Path] { var directories: [Path] {
return compactMap { return compactMap {
@@ -30,7 +51,14 @@ public extension Array where Element == Path.Entry {
} }
} }
/// Filters the list of entries to be a list of Paths that are files with the specified extension /// Filters the list of entries to be a list of Paths that are files.
var files: [Path] {
return compactMap {
$0.kind == .file ? $0.path : nil
}
}
/// Filters the list of entries to be a list of Paths that are files with the specified extension.
func files(withExtension ext: String) -> [Path] { 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,25 +1,16 @@
import Foundation import Foundation
#if os(Linux)
import func Glibc.access
#else
import func Darwin.access
#endif
public extension Path { public extension Path {
/// Returns true if the path represents an actual file that is also writable by the current user. //MARK: Filesystem Properties
var isWritable: Bool {
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. /// Returns true if the path represents an actual filesystem entry.
var isDirectory: Bool { var exists: Bool {
var isDir: ObjCBool = false return FileManager.default.fileExists(atPath: string)
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. /// Returns true if the path represents an actual filesystem entry that is *not* a directory.
@@ -28,13 +19,40 @@ public extension Path {
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. /// Returns true if the path represents an actual directory.
var isExecutable: Bool { var isDirectory: Bool {
return FileManager.default.isExecutableFile(atPath: string) var isDir: ObjCBool = false
return FileManager.default.fileExists(atPath: string, isDirectory: &isDir) && isDir.boolValue
} }
/// Returns true if the path represents an actual filesystem entry. /// Returns true if the path represents an actual file that is also readable by the current user.
var exists: Bool { var isReadable: Bool {
return FileManager.default.fileExists(atPath: string) return FileManager.default.isReadableFile(atPath: string)
}
/// Returns true if the path represents an actual file that is also writable by the current user.
var isWritable: Bool {
return FileManager.default.isWritableFile(atPath: string)
}
/// Returns true if the path represents an actual file that is also deletable by the current user.
var isDeletable: Bool {
#if os(Linux) && !swift(>=5.1)
return exists && access(parent.string, W_OK) == 0
#else
// FileManager.isDeletableFile returns true if there is *not* a file there
return exists && FileManager.default.isDeletableFile(atPath: string)
#endif
}
/// Returns true if the path represents an actual file that is also executable by the current user.
var isExecutable: Bool {
if access(string, X_OK) == 0 {
// FileManager.isExxecutableFile returns true even if there is *not*
// a file there *but* if there was it could be *made* executable
return FileManager.default.isExecutableFile(atPath: string)
} else {
return false
}
} }
} }

View File

@@ -1,28 +1,60 @@
import Foundation import Foundation
/** /**
Represents a platform filesystem absolute path. A `Path` represents an absolute path on a filesystem.
The recommended conversions from string are: All functions on `Path` are chainable and short to facilitate doing sequences
of file operations in a concise manner.
`Path` supports `Codable`, and can be configured to
[encode paths *relatively*](https://github.com/mxcl/Path.swift/#codable).
Sorting a `Sequence` of paths will return the locale-aware sort order, which
will give you the same order as Finder.
Converting from a `String` is a common first step, here are the recommended
ways to do that:
let p1 = Path.root/pathString let p1 = Path.root/pathString
let p2 = Path.root/url.path let p2 = Path.root/url.path
let p3 = Path.cwd/relativePathString let p3 = Path.cwd/relativePathString
let p4 = Path(userInput) ?? Path.cwd/userInput let p4 = Path(userInput) ?? Path.cwd/userInput
- Note: There may not be an actual filesystem entry at the path. If you are constructing paths from static-strings we provide support for
dynamic members:
let p1 = Path.root.usr.bin.ls // => /usr/bin/ls
- Note: A `Path` does not necessarily represent an actual filesystem entry.
*/ */
@dynamicMemberLookup
public struct Path: Equatable, Hashable, Comparable { public struct Path: Equatable, Hashable, Comparable {
init(string: String) {
self.string = string
}
/// 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)
}
/// :nodoc:
public subscript(dynamicMember pathComponent: String) -> Path {
let str = (string as NSString).appendingPathComponent(pathComponent)
return Path(string: str)
}
//MARK: Properties
/// The underlying filesystem path /// The underlying filesystem path
public let string: String public let string: String
/** /// Returns a `URL` representing this file path.
Returns the filename extension of this path. public var url: URL {
- Remark: Implemented via `NSString.pathExtension`. return URL(fileURLWithPath: string)
*/
@inlinable
public var `extension`: String {
return (string as NSString).pathExtension
} }
/** /**
@@ -37,33 +69,56 @@ public struct Path: Equatable, Hashable, Comparable {
return Path(string: (string as NSString).deletingLastPathComponent) return Path(string: (string as NSString).deletingLastPathComponent)
} }
/// Returns a `URL` representing this file path. /**
Returns the filename extension of this path.
- Remark: Implemented via `NSString.pathExtension`.
- Note: We special case eg. `foo.tar.gz`.
*/
@inlinable @inlinable
public var url: URL { public var `extension`: String {
return URL(fileURLWithPath: string) if string.hasSuffix(".tar.gz") {
return "tar.gz"
} else {
return (string as NSString).pathExtension
}
}
//MARK: Pathing
/**
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: `Path./(_:_:)`
*/
public func join<S>(_ pathComponent: S) -> Path where S: StringProtocol {
//TODO standardizingPath does more than we want really (eg tilde expansion)
let str = (string as NSString).appendingPathComponent(String(pathComponent))
return Path(string: (str as NSString).standardizingPath)
} }
/** /**
The basename for the provided file, optionally dropping the file extension. Joins a path and a string to produce a new path.
Path.root.join("foo.swift").basename() // => "foo.swift" Path.root/"a" // => /a
Path.root.join("foo.swift").basename(dropExtension: true) // => "foo" Path.root/"a/b" // => /a/b
Path.root/"a"/"b" // => /a/b
Path.root/"a"/"/b" // => /a/b
- Returns: A string that is the filenames basename. - Parameter lhs: The base path to join with `rhs`.
- Parameter dropExtension: If `true` returns the basename without its file extension. - Parameter rhs: The string to join with this `lhs`.
- Returns: A new joined path.
- SeeAlso: `join(_:)`
*/ */
public func basename(dropExtension: Bool = false) -> String { @inlinable
let str = string as NSString public static func /<S>(lhs: Path, rhs: S) -> Path where S: StringProtocol {
if !dropExtension { return lhs.join(rhs)
return str.lastPathComponent
} else {
let ext = str.pathExtension
if !ext.isEmpty {
return String(str.lastPathComponent.dropLast(ext.count + 1))
} else {
return str.lastPathComponent
}
}
} }
/** /**
@@ -104,59 +159,32 @@ public struct Path: Equatable, Hashable, Comparable {
} }
/** /**
Joins a path and a string to produce a new path. The basename for the provided file, optionally dropping the file extension.
Path.root.join("a") // => /a Path.root.join("foo.swift").basename() // => "foo.swift"
Path.root.join("a/b") // => /a/b Path.root.join("foo.swift").basename(dropExtension: true) // => "foo"
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 string that is the filenames basename.
- Returns: A new joined path. - Parameter dropExtension: If `true` returns the basename without its file extension.
- SeeAlso: `/(_:, _:)`
*/ */
public func join<S>(_ pathComponent: S) -> Path where S: StringProtocol { public func basename(dropExtension: Bool = false) -> String {
//TODO standardizingPath does more than we want really (eg tilde expansion) let str = string as NSString
let str = (string as NSString).appendingPathComponent(String(pathComponent)) if !dropExtension {
return Path(string: (str as NSString).standardizingPath) return str.lastPathComponent
} else {
let ext = str.pathExtension
if !ext.isEmpty {
return String(str.lastPathComponent.dropLast(ext.count + 1))
} else {
return str.lastPathComponent
}
}
} }
/// Returns the locale-aware sort order for the two paths. /// Returns the locale-aware sort order for the two paths.
/// :nodoc:
@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 {
/// The kind of this directory entry.
public enum Kind {
/// The path is a file.
case file
/// The path is a directory.
case directory
}
/// The kind of this entry.
public let kind: Kind
/// The path of this entry.
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
public func /<S>(lhs: Path, rhs: S) -> Path where S: StringProtocol {
return lhs.join(rhs)
} }

View File

@@ -1,5 +1,5 @@
@testable import Path
import XCTest import XCTest
import Path
class PathTests: XCTestCase { class PathTests: XCTestCase {
func testConcatenation() { func testConcatenation() {
@@ -14,21 +14,27 @@ class PathTests: XCTestCase {
func testEnumeration() throws { func testEnumeration() throws {
let tmpdir_ = try TemporaryDirectory() let tmpdir_ = try TemporaryDirectory()
let tmpdir = tmpdir_.path let tmpdir = tmpdir_.path
try tmpdir.join("a").mkdir().join("c").touch() try tmpdir.a.mkdir().c.touch()
try tmpdir.join("b").touch() try tmpdir.join("b.swift").touch()
try tmpdir.join("c").touch() try tmpdir.c.touch()
try tmpdir.join(".d").mkdir().join("e").touch() try tmpdir.join(".d").mkdir().e.touch()
var paths = Set<String>() var paths = Set<String>()
let lsrv = try tmpdir.ls()
var dirs = 0 var dirs = 0
for entry in try tmpdir.ls() { for entry in lsrv {
if entry.kind == .directory { if entry.kind == .directory {
dirs += 1 dirs += 1
} }
paths.insert(entry.path.basename()) paths.insert(entry.path.basename())
} }
XCTAssertEqual(dirs, 2) XCTAssertEqual(dirs, 2)
XCTAssertEqual(paths, ["a", "b", "c", ".d"]) XCTAssertEqual(dirs, lsrv.directories.count)
XCTAssertEqual(["a", ".d"], Set(lsrv.directories.map{ $0.relative(to: tmpdir) }))
XCTAssertEqual(["b.swift", "c"], Set(lsrv.files.map{ $0.relative(to: tmpdir) }))
XCTAssertEqual(["b.swift"], Set(lsrv.files(withExtension: "swift").map{ $0.relative(to: tmpdir) }))
XCTAssertEqual(["c"], Set(lsrv.files(withExtension: "").map{ $0.relative(to: tmpdir) }))
XCTAssertEqual(paths, ["a", "b.swift", "c", ".d"])
} }
@@ -69,6 +75,18 @@ class PathTests: XCTestCase {
XCTAssert((Path.root/"bin").isDirectory) XCTAssert((Path.root/"bin").isDirectory)
} }
func testExtension() {
XCTAssertEqual(Path.root.join("a.swift").extension, "swift")
XCTAssertEqual(Path.root.join("a").extension, "")
XCTAssertEqual(Path.root.join("a.").extension, "")
XCTAssertEqual(Path.root.join("a..").extension, "")
XCTAssertEqual(Path.root.join("a..swift").extension, "swift")
XCTAssertEqual(Path.root.join("a..swift.").extension, "")
XCTAssertEqual(Path.root.join("a.tar.gz").extension, "tar.gz")
XCTAssertEqual(Path.root.join("a..tar.gz").extension, "tar.gz")
XCTAssertEqual(Path.root.join("a..tar..gz").extension, "gz")
}
func testMktemp() throws { func testMktemp() throws {
var path: Path! var path: Path!
try Path.mktemp { try Path.mktemp {
@@ -83,7 +101,7 @@ class PathTests: XCTestCase {
try Path.mktemp { try Path.mktemp {
for _ in 0...1 { for _ in 0...1 {
try $0.join("a").mkdir() try $0.join("a").mkdir()
try $0.join("b/c").mkpath() try $0.join("b/c").mkdir(.p)
} }
} }
} }
@@ -139,14 +157,271 @@ class PathTests: XCTestCase {
XCTAssertEqual(Path.root/"a/foo"/"../../../bar", Path.root/"bar") XCTAssertEqual(Path.root/"a/foo"/"../../../bar", Path.root/"bar")
} }
func testCopyInto() throws { func testDynamicMember() {
XCTAssertEqual(Path.root.Documents, Path.root/"Documents")
let a = Path.home.foo
XCTAssertEqual(a.Documents, Path.home/"foo/Documents")
}
func testCopyTo() throws {
try Path.mktemp { root in try Path.mktemp { root in
let bar = try root.join("bar").touch() try root.foo.touch().copy(to: root.bar)
try Path.mktemp { root in XCTAssert(root.foo.isFile)
try root.join("bar").touch() XCTAssert(root.bar.isFile)
XCTAssertThrowsError(try bar.copy(into: root)) XCTAssertThrowsError(try root.foo.copy(to: root.bar))
try bar.copy(into: root, overwrite: true) try root.foo.copy(to: root.bar, overwrite: true)
}
}
func testCopyInto() throws {
try Path.mktemp { root1 in
let bar1 = try root1.join("bar").touch()
try Path.mktemp { root2 in
let bar2 = try root2.join("bar").touch()
XCTAssertThrowsError(try bar1.copy(into: root2))
try bar1.copy(into: root2, overwrite: true)
XCTAssertTrue(bar1.exists)
XCTAssertTrue(bar2.exists)
}
// test creates intermediary directories
try bar1.copy(into: root1.create.directories)
// test doesnt replace file if copy into a file
let d = try root1.fuz.touch()
XCTAssertThrowsError(try root1.baz.touch().copy(into: d))
XCTAssert(d.isFile)
XCTAssert(root1.baz.isFile)
}
}
func testMoveTo() throws {
try Path.mktemp { tmpdir in
try tmpdir.foo.touch().move(to: tmpdir.bar)
XCTAssertFalse(tmpdir.foo.exists)
XCTAssert(tmpdir.bar.isFile)
XCTAssertThrowsError(try tmpdir.foo.touch().move(to: tmpdir.bar))
try tmpdir.foo.move(to: tmpdir.bar, overwrite: true)
}
}
func testMoveInto() throws {
try Path.mktemp { root1 in
let bar1 = try root1.join("bar").touch()
try Path.mktemp { root2 in
let bar2 = try root2.join("bar").touch()
XCTAssertThrowsError(try bar1.move(into: root2))
try bar1.move(into: root2, overwrite: true)
XCTAssertFalse(bar1.exists)
XCTAssertTrue(bar2.exists)
}
// test creates intermediary directories
try root1.baz.touch().move(into: root1.create.directories)
XCTAssertFalse(root1.baz.exists)
XCTAssert(root1.create.directories.baz.isFile)
// test doesnt replace file if move into a file
let d = try root1.fuz.touch()
XCTAssertThrowsError(try root1.baz.touch().move(into: d))
XCTAssert(d.isFile)
XCTAssert(root1.baz.isFile)
}
}
func testRename() throws {
try Path.mktemp { root in
do {
let file = try root.bar.touch()
let foo = try file.rename(to: "foo")
XCTAssertFalse(file.exists)
XCTAssertTrue(foo.isFile)
}
do {
let file = try root.bar.touch()
XCTAssertThrowsError(try file.rename(to: "foo"))
} }
} }
} }
func testCommonDirectories() {
XCTAssertEqual(Path.root.string, "/")
XCTAssertEqual(Path.home.string, NSHomeDirectory())
XCTAssertEqual(Path.documents.string, NSHomeDirectory() + "/Documents")
#if !os(Linux)
XCTAssertEqual(Path.caches.string, NSHomeDirectory() + "/Library/Caches")
XCTAssertEqual(Path.cwd.string, FileManager.default.currentDirectoryPath)
XCTAssertEqual(Path.applicationSupport.string, NSHomeDirectory() + "/Library/Application Support")
_ = defaultUrl(for: .documentDirectory)
_ = defaultUrl(for: .cachesDirectory)
_ = defaultUrl(for: .applicationSupportDirectory)
#endif
}
func testStringConvertibles() {
XCTAssertEqual(Path.root.description, "/")
XCTAssertEqual(Path.root.debugDescription, "Path(/)")
}
func testFilesystemAttributes() throws {
XCTAssert(Path(#file)!.isFile)
XCTAssert(Path(#file)!.isReadable)
XCTAssert(Path(#file)!.isWritable)
XCTAssert(Path(#file)!.isDeletable)
XCTAssert(Path(#file)!.parent.isDirectory)
try Path.mktemp { tmpdir in
XCTAssertTrue(tmpdir.isDirectory)
XCTAssertFalse(tmpdir.isFile)
let bar = try tmpdir.bar.touch().chmod(0o000)
XCTAssertFalse(bar.isReadable)
XCTAssertFalse(bar.isWritable)
XCTAssertFalse(bar.isDirectory)
XCTAssertFalse(bar.isExecutable)
XCTAssertTrue(bar.isFile)
XCTAssertTrue(bar.isDeletable) // can delete even if no read permissions
try bar.chmod(0o777)
XCTAssertTrue(bar.isReadable)
XCTAssertTrue(bar.isWritable)
XCTAssertTrue(bar.isDeletable)
XCTAssertTrue(bar.isExecutable)
try bar.delete()
XCTAssertFalse(bar.exists)
XCTAssertFalse(bar.isReadable)
XCTAssertFalse(bar.isWritable)
XCTAssertFalse(bar.isExecutable)
XCTAssertFalse(bar.isDeletable)
let nonExistantFile = tmpdir.baz
XCTAssertFalse(nonExistantFile.exists)
XCTAssertFalse(nonExistantFile.isExecutable)
XCTAssertFalse(nonExistantFile.isReadable)
XCTAssertFalse(nonExistantFile.isWritable)
XCTAssertFalse(nonExistantFile.isDeletable)
XCTAssertFalse(nonExistantFile.isDirectory)
XCTAssertFalse(nonExistantFile.isFile)
let baz = try tmpdir.baz.touch()
XCTAssertTrue(baz.isDeletable)
try tmpdir.chmod(0o500) // remove write permission on directory
XCTAssertFalse(baz.isDeletable) // this is how deletion is prevented on UNIX
}
}
func testTimes() throws {
try Path.mktemp { tmpdir in
let foo = try tmpdir.foo.touch()
let now1 = Date().timeIntervalSince1970.rounded(.down)
#if !os(Linux)
XCTAssertEqual(foo.ctime?.timeIntervalSince1970.rounded(.down), now1) //FIXME flakey
#endif
XCTAssertEqual(foo.mtime?.timeIntervalSince1970.rounded(.down), now1) //FIXME flakey
sleep(1)
try foo.touch()
let now2 = Date().timeIntervalSince1970.rounded(.down)
XCTAssertNotEqual(now1, now2)
XCTAssertEqual(foo.mtime?.timeIntervalSince1970.rounded(.down), now2) //FIXME flakey
XCTAssertNil(tmpdir.void.mtime)
XCTAssertNil(tmpdir.void.ctime)
}
}
func testDelete() throws {
try Path.mktemp { tmpdir in
try tmpdir.bar1.delete()
try tmpdir.bar2.touch().delete()
try tmpdir.bar3.touch().chmod(0o000).delete()
#if !os(Linux)
XCTAssertThrowsError(try tmpdir.bar3.touch().lock().delete())
#endif
}
}
func testRelativeCodable() throws {
let path = Path.home.foo
let encoder = JSONEncoder()
encoder.userInfo[.relativePath] = Path.home
let data = try encoder.encode([path])
let decoder = JSONDecoder()
decoder.userInfo[.relativePath] = Path.home
XCTAssertEqual(try decoder.decode([Path].self, from: data), [path])
decoder.userInfo[.relativePath] = Path.documents
XCTAssertEqual(try decoder.decode([Path].self, from: data), [Path.documents.foo])
XCTAssertThrowsError(try JSONDecoder().decode([Path].self, from: data))
}
func testBundleExtensions() throws {
try Path.mktemp { tmpdir in
let bndl = Bundle(path: tmpdir.string)!
XCTAssertEqual(bndl.path, tmpdir)
XCTAssertEqual(bndl.sharedFrameworks, tmpdir.SharedFrameworks)
XCTAssertEqual(bndl.privateFrameworks, tmpdir.Frameworks)
XCTAssertEqual(bndl.resources, tmpdir)
XCTAssertNil(bndl.path(forResource: "foo", ofType: "bar"))
#if os(macOS)
XCTAssertEqual(bndl.defaultSharedFrameworksPath, tmpdir.Contents.Frameworks)
XCTAssertEqual(bndl.defaultResourcesPath, tmpdir.Contents.Resources)
#elseif os(tvOS) || os(iOS)
XCTAssertEqual(bndl.defaultSharedFrameworksPath, tmpdir.Frameworks)
XCTAssertEqual(bndl.defaultResourcesPath, tmpdir)
#else
XCTAssertEqual(bndl.defaultSharedFrameworksPath, tmpdir.lib)
XCTAssertEqual(bndl.defaultResourcesPath, tmpdir.share)
#endif
}
}
func testDataExtensions() throws {
let data = try Data(contentsOf: Path(#file)!)
try Path.mktemp { tmpdir in
_ = try data.write(to: tmpdir.foo)
_ = try data.write(to: tmpdir.foo, atomically: true)
}
}
func testStringExtensions() throws {
let string = try String(contentsOf: Path(#file)!)
try Path.mktemp { tmpdir in
_ = try string.write(to: tmpdir.foo)
}
}
func testFileHandleExtensions() throws {
_ = try FileHandle(forReadingAt: Path(#file)!)
_ = try FileHandle(forWritingAt: Path(#file)!)
_ = try FileHandle(forUpdatingAt: Path(#file)!)
}
func testSort() {
XCTAssertEqual([Path.root.a, Path.root.c, Path.root.b].sorted(), [Path.root.a, Path.root.b, Path.root.c])
}
func testLock() throws {
#if !os(Linux)
try Path.mktemp { tmpdir in
let bar = try tmpdir.bar.touch()
try bar.lock()
XCTAssertThrowsError(try bar.touch())
try bar.unlock()
try bar.touch()
// a non existant file is already unlocked
try tmpdir.nonExit.unlock()
}
#endif
}
func testTouchThrowsIfCannotWrite() throws {
try Path.mktemp { tmpdir in
try tmpdir.chmod(0o000)
XCTAssertThrowsError(try tmpdir.bar.touch())
}
}
} }

View File

@@ -42,7 +42,11 @@ class TemporaryDirectory {
} }
deinit { deinit {
_ = try? FileManager.default.removeItem(at: url) do {
try path.chmod(0o777).delete()
} catch {
//TODO log
}
} }
} }

View File

@@ -3,18 +3,37 @@ import XCTest
extension PathTests { extension PathTests {
static let __allTests = [ static let __allTests = [
("testBasename", testBasename), ("testBasename", testBasename),
("testBundleExtensions", testBundleExtensions),
("testCodable", testCodable), ("testCodable", testCodable),
("testCommonDirectories", testCommonDirectories),
("testConcatenation", testConcatenation), ("testConcatenation", testConcatenation),
("testCopyInto", testCopyInto), ("testCopyInto", testCopyInto),
("testCopyTo", testCopyTo),
("testDataExtensions", testDataExtensions),
("testDelete", testDelete),
("testDynamicMember", testDynamicMember),
("testEnumeration", testEnumeration), ("testEnumeration", testEnumeration),
("testEnumerationSkippingHiddenFiles", testEnumerationSkippingHiddenFiles), ("testEnumerationSkippingHiddenFiles", testEnumerationSkippingHiddenFiles),
("testExists", testExists), ("testExists", testExists),
("testExtension", testExtension),
("testFileHandleExtensions", testFileHandleExtensions),
("testFilesystemAttributes", testFilesystemAttributes),
("testIsDirectory", testIsDirectory), ("testIsDirectory", testIsDirectory),
("testJoin", testJoin), ("testJoin", testJoin),
("testLock", testLock),
("testMkpathIfExists", testMkpathIfExists), ("testMkpathIfExists", testMkpathIfExists),
("testMktemp", testMktemp), ("testMktemp", testMktemp),
("testMoveInto", testMoveInto),
("testMoveTo", testMoveTo),
("testRelativeCodable", testRelativeCodable),
("testRelativePathCodable", testRelativePathCodable), ("testRelativePathCodable", testRelativePathCodable),
("testRelativeTo", testRelativeTo), ("testRelativeTo", testRelativeTo),
("testRename", testRename),
("testSort", testSort),
("testStringConvertibles", testStringConvertibles),
("testStringExtensions", testStringExtensions),
("testTimes", testTimes),
("testTouchThrowsIfCannotWrite", testTouchThrowsIfCannotWrite),
] ]
} }