Compare commits

...

32 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
15 changed files with 549 additions and 109 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

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
@@ -88,12 +90,15 @@ jobs:
tags: true tags: true
- name: CocoaPods - name: CocoaPods
before_install: | before_install: export TRAVIS_REPO_NAME=${TRAVIS_REPO_SLUG#*/}
install: gem install cocoapods
before_script: |
export DESCRIPTION=$(swift - <<\ \ EOF export DESCRIPTION=$(swift - <<\ \ EOF
import Foundation import Foundation
struct Response: Decodable { let description: String } struct Response: Decodable { let description: String }
let token = ProcessInfo.processInfo.environment["GITHUB_TOKEN"]! let token = ProcessInfo.processInfo.environment["GITHUB_TOKEN"]!
let url = URL(string: "https://api.github.com/repos/mxcl/Path.swift")! let slug = ProcessInfo.processInfo.environment["TRAVIS_REPO_SLUG"]!
let url = URL(string: "https://api.github.com/repos/\(slug)")!
var rq = URLRequest(url: url) var rq = URLRequest(url: url)
rq.setValue("token \(token)", forHTTPHeaderField: "Authorization") rq.setValue("token \(token)", forHTTPHeaderField: "Authorization")
let semaphore = DispatchSemaphore(value: 0) let semaphore = DispatchSemaphore(value: 0)
@@ -106,15 +111,15 @@ jobs:
let rsp = try JSONDecoder().decode(Response.self, from: data) let rsp = try JSONDecoder().decode(Response.self, from: data)
print(rsp.description, terminator: "") print(rsp.description, terminator: "")
EOF) EOF)
cat <<\ \ EOF> Path.swift.podspec 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 = ENV['TRAVIS_TAG'] s.version = ENV['TRAVIS_TAG']
s.summary = ENV['DESCRIPTION'] 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'
@@ -124,5 +129,4 @@ jobs:
s.swift_version = '4.2' s.swift_version = '4.2'
end end
EOF EOF
install: gem install cocoapods --pre
script: pod trunk push script: pod trunk push

View File

@@ -1,4 +1,4 @@
# 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 end A file-system pathing library focused on developer experience and robust end
results. results.
@@ -33,10 +33,10 @@ print(foo) // => /bar/foo
print(foo.isFile) // => true print(foo.isFile) // => true
// we support dynamic members (_use_sparingly_): // we support dynamic members (_use_sparingly_):
let prefs = Path.home.Library.Preferences let prefs = Path.home.Library.Preferences // => /Users/mxcl/Library/Preferences
// a practical example: installing a helper executable // a practical example: installing a helper executable
try Bundle.resources.join("helper").copy(into: Path.home.join(".local/bin").mkdir(.p)).chmod(0o500) 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
@@ -57,7 +57,8 @@ help me continue my work, 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
@@ -107,8 +108,9 @@ let ls = Path.root.usr.bin.ls // => /usr/bin/ls
``` ```
This is less commonly useful than you would think, hence our documentation 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. does not use it. Usually you are joining variables or other `String` arguments
However when you need it, its *lovely*. 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
@@ -160,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.
@@ -199,7 +215,7 @@ Path("~/foo")! // => /Users/mxcl/foo
Path("~foo") // => nil Path("~foo") // => nil
``` ```
*Path.swift* has the general policty that if the desired end result preexists, *Path.swift* has the general policy that if the desired end result preexists,
then its a noop: then its a noop:
* If you try to delete a file, but the file doesn't exist, we do nothing. * If you try to delete a file, but the file doesn't exist, we do nothing.
@@ -214,13 +230,13 @@ for that as the check was deemed too expensive to be worthwhile.
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:
@@ -238,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,32 +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. /**
Returns the path for the shared-frameworks directory in this bundle.
- Note: This is typically `ShareFrameworks`
*/
var sharedFrameworks: Path { var sharedFrameworks: Path {
var `default`: Path { return sharedFrameworksPath.flatMap(Path.init) ?? defaultSharedFrameworksPath
#if os(macOS)
return path.join("Contents/Frameworks")
#elseif os(Linux)
return path.join("lib")
#else
return path.join("Frameworks")
#endif
} }
return sharedFrameworksPath.flatMap(Path.init) ?? `default`
/**
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 {
var `default`: Path { return resourcePath.flatMap(Path.init) ?? defaultResourcesPath
#if os(macOS)
return path.join("Contents/Resources")
#elseif os(Linux)
return path.join("share")
#else
return path
#endif
}
return resourcePath.flatMap(Path.init) ?? `default`
} }
/// Returns the path for this bundle. /// Returns the path for this bundle.
@@ -86,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

@@ -5,30 +5,28 @@ public extension Path {
/** /**
Returns the creation-time of the file. Returns the creation-time of the file.
- Note: Returns UNIX-time-zero if there is no creation-time, this should only happen if the file doesnt exist. - Note: Returns `nil` if there is no creation-time, this should only happen if the file doesnt exist.
- Important: On Linux this is filesystem dependendent and may not exist.
*/ */
var ctime: Date { var ctime: Date? {
do { do {
let attrs = try FileManager.default.attributesOfItem(atPath: string) let attrs = try FileManager.default.attributesOfItem(atPath: string)
return attrs[.creationDate] as? Date ?? Date(timeIntervalSince1970: 0) return attrs[.creationDate] as? Date
} catch { } catch {
//TODO log error return nil
return Date(timeIntervalSince1970: 0)
} }
} }
/** /**
Returns the modification-time of the file. Returns the modification-time of the file.
- Note: Returns the creation time if there is no modification time. - Note: If this returns `nil` and the file exists, something is very wrong.
- Note: Returns UNIX-time-zero if neither are available, this should only happen if the file doesnt exist.
*/ */
var mtime: Date { 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 ?? ctime return attrs[.modificationDate] as? Date
} catch { } catch {
//TODO log error return nil
return Date(timeIntervalSince1970: 0)
} }
} }
@@ -43,23 +41,34 @@ public extension Path {
return self return self
} }
/// - Note: If file is already locked, does nothing /**
/// - Note: If file doesnt exist, throws 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 @discardableResult
func lock() throws -> Path { func lock() throws -> Path {
#if !os(Linux)
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 {
attrs[.immutable] = true attrs[.immutable] = true
try FileManager.default.setAttributes(attrs, ofItemAtPath: string) try FileManager.default.setAttributes(attrs, ofItemAtPath: string)
} }
#endif
return self return self
} }
/// - Note: If file isnt locked, does nothing /**
/// - Note: If file doesnt exist, does nothing - Note: If file isnt locked, does nothing.
- Note: If file doesnt exist, does nothing.
- Important: On Linux does nothing.
- SeeAlso: `lock()`
*/
@discardableResult @discardableResult
func unlock() throws -> Path { func unlock() throws -> Path {
#if !os(Linux)
var attrs: [FileAttributeKey: Any] var attrs: [FileAttributeKey: Any]
do { do {
attrs = try FileManager.default.attributesOfItem(atPath: string) attrs = try FileManager.default.attributesOfItem(atPath: string)
@@ -71,6 +80,7 @@ public extension Path {
attrs[.immutable] = false attrs[.immutable] = false
try FileManager.default.setAttributes(attrs, ofItemAtPath: string) try FileManager.default.setAttributes(attrs, ofItemAtPath: string)
} }
#endif
return self return self
} }
} }

View File

@@ -47,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
} }
@@ -90,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,4 +1,7 @@
import Foundation import Foundation
#if os(Linux)
import Glibc
#endif
public extension Path { public extension Path {
//MARK: File Management //MARK: File Management
@@ -25,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
} }
@@ -38,7 +46,8 @@ public extension Path {
// Create ~/.local/bin, copy `ls` there and make the new copy executable // 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) 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 first. If the destination does not exist, this function creates the directory
(including intermediary directories if necessary) first.
- 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`.
@@ -48,25 +57,21 @@ public extension Path {
`self` because even though *Path.swifts* policy is to noop if the desired `self` because even though *Path.swifts* policy is to noop if the desired
end result preexists, checking for this condition is too expensive a end result preexists, checking for this condition is too expensive a
trade-off. trade-off.
- SeeAlso: `copy(into:overwrite:)` - 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
@@ -86,11 +91,11 @@ public extension Path {
`self` because even though *Path.swifts* policy is to noop if the desired `self` because even though *Path.swifts* policy is to noop if the desired
end result preexists, checking for this condition is too expensive a end result preexists, checking for this condition is too expensive a
trade-off. trade-off.
- SeeAlso: move(into:overwrite:) - 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)
@@ -103,22 +108,26 @@ public extension Path {
try Path.root.join("bar").move(into: .home) try Path.root.join("bar").move(into: .home)
// => "/Users/mxcl/bar" // => "/Users/mxcl/bar"
If the destination does not exist, this function creates the directory first. If the destination does not exist, this function creates the directory
(including intermediary directories if necessary) first.
- 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. - 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.mkdir(.p) 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
} }
@@ -127,6 +136,9 @@ public extension Path {
Deletes the path, recursively if a directory. Deletes the path, recursively if a directory.
- Note: noop: if the path doesnt exist - Note: noop: if the path doesnt exist
*Path.swift* doesnt error if desired end result preexists. *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 {
@@ -136,13 +148,26 @@ public extension Path {
} }
/** /**
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
} }
/** /**
@@ -171,6 +196,21 @@ public extension Path {
} }
return self return self
} }
/**
Renames the file at path.
Path.root.foo.bar.rename(to: "baz") // => /foo/baz
- Parameter to: the new basename for the file
- Returns: The renamed path.
*/
@discardableResult
func rename(to newname: String) throws -> Path {
let newpath = parent/newname
try FileManager.default.moveItem(atPath: string, toPath: newpath.string)
return newpath
}
} }
/// Options for `Path.mkdir(_:)` /// Options for `Path.mkdir(_:)`

View File

@@ -10,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

@@ -51,7 +51,14 @@ public extension Array where Element == 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,4 +1,9 @@
import Foundation import Foundation
#if os(Linux)
import func Glibc.access
#else
import func Darwin.access
#endif
public extension Path { public extension Path {
//MARK: Filesystem Properties //MARK: Filesystem Properties
@@ -32,11 +37,22 @@ public extension Path {
/// Returns true if the path represents an actual file that is also deletable by the current user. /// Returns true if the path represents an actual file that is also deletable by the current user.
var isDeletable: Bool { var isDeletable: Bool {
return FileManager.default.isDeletableFile(atPath: string) #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. /// Returns true if the path represents an actual file that is also executable by the current user.
var isExecutable: Bool { 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) return FileManager.default.isExecutableFile(atPath: string)
} else {
return false
}
} }
} }

View File

@@ -9,7 +9,7 @@ import Foundation
`Path` supports `Codable`, and can be configured to `Path` supports `Codable`, and can be configured to
[encode paths *relatively*](https://github.com/mxcl/Path.swift/#codable). [encode paths *relatively*](https://github.com/mxcl/Path.swift/#codable).
Sorting a `Sequence` of `Path`s will return the locale-aware sort order, which Sorting a `Sequence` of paths will return the locale-aware sort order, which
will give you the same order as Finder. will give you the same order as Finder.
Converting from a `String` is a common first step, here are the recommended Converting from a `String` is a common first step, here are the recommended
@@ -20,13 +20,12 @@ import Foundation
let p3 = Path.cwd/relativePathString let p3 = Path.cwd/relativePathString
let p4 = Path(userInput) ?? Path.cwd/userInput let p4 = Path(userInput) ?? Path.cwd/userInput
If you are constructing Paths from static-strings we provide support for If you are constructing paths from static-strings we provide support for
dynamic members: dynamic members:
let p1 = Path.root.usr.bin.ls // => /usr/bin/ls let p1 = Path.root.usr.bin.ls // => /usr/bin/ls
- Note: There may not be an actual filesystem entry at the path. The underlying - Note: A `Path` does not necessarily represent an actual filesystem entry.
representation for `Path` is `String`.
*/ */
@dynamicMemberLookup @dynamicMemberLookup
@@ -36,7 +35,7 @@ public struct Path: Equatable, Hashable, Comparable {
self.string = string self.string = string
} }
/// Returns `nil` unless fed an absolute path /// Returns `nil` unless fed an absolute path.
public init?(_ description: String) { public init?(_ description: String) {
guard description.starts(with: "/") || description.starts(with: "~/") else { return nil } guard description.starts(with: "/") || description.starts(with: "~/") else { return nil }
self.init(string: (description as NSString).standardizingPath) self.init(string: (description as NSString).standardizingPath)
@@ -73,11 +72,16 @@ public struct Path: Equatable, Hashable, Comparable {
/** /**
Returns the filename extension of this path. Returns the filename extension of this path.
- Remark: Implemented via `NSString.pathExtension`. - Remark: Implemented via `NSString.pathExtension`.
- Note: We special case eg. `foo.tar.gz`.
*/ */
@inlinable @inlinable
public var `extension`: String { public var `extension`: String {
if string.hasSuffix(".tar.gz") {
return "tar.gz"
} else {
return (string as NSString).pathExtension return (string as NSString).pathExtension
} }
}
//MARK: Pathing //MARK: Pathing
@@ -91,7 +95,7 @@ public struct Path: Equatable, Hashable, Comparable {
- Parameter pathComponent: The string to join with this path. - Parameter pathComponent: The string to join with this path.
- Returns: A new joined path. - Returns: A new joined path.
- SeeAlso: `Path./(_:, _:)` - SeeAlso: `Path./(_:_:)`
*/ */
public func join<S>(_ pathComponent: S) -> Path where S: StringProtocol { 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)

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() {
@@ -15,20 +15,26 @@ class PathTests: XCTestCase {
let tmpdir_ = try TemporaryDirectory() let tmpdir_ = try TemporaryDirectory()
let tmpdir = tmpdir_.path let tmpdir = tmpdir_.path
try tmpdir.a.mkdir().c.touch() try tmpdir.a.mkdir().c.touch()
try tmpdir.b.touch() try tmpdir.join("b.swift").touch()
try tmpdir.c.touch() try tmpdir.c.touch()
try tmpdir.join(".d").mkdir().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 {
@@ -146,14 +164,264 @@ class PathTests: XCTestCase {
XCTAssertEqual(a.Documents, Path.home/"foo/Documents") XCTAssertEqual(a.Documents, Path.home/"foo/Documents")
} }
func testCopyTo() throws {
try Path.mktemp { root in
try root.foo.touch().copy(to: root.bar)
XCTAssert(root.foo.isFile)
XCTAssert(root.bar.isFile)
XCTAssertThrowsError(try root.foo.copy(to: root.bar))
try root.foo.copy(to: root.bar, overwrite: true)
}
}
func testCopyInto() throws { 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 try Path.mktemp { root in
let bar = try root.join("bar").touch() do {
try Path.mktemp { root in let file = try root.bar.touch()
try root.join("bar").touch() let foo = try file.rename(to: "foo")
XCTAssertThrowsError(try bar.copy(into: root)) XCTAssertFalse(file.exists)
try bar.copy(into: root, overwrite: true) 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,19 +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), ("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),
] ]
} }