Compare commits

...

37 Commits

Author SHA1 Message Date
Max Howell
39f81ae258 Fix pods deploy 2021-05-28 16:23:47 -04:00
Max Howell
670dc1163f Merge pull request #68 from mxcl/ci/more 2021-05-28 16:05:44 -04:00
Max Howell
eb33ff8906 [ci] more; some fixes I found 2021-05-28 16:01:57 -04:00
Max Howell
f9cee2c75f Merge pull request #67
[ci] fix
2021-04-30 08:20:56 -04:00
Max Howell
7a974911d8 [ci] fix 2021-04-30 08:06:10 -04:00
Max Howell
891d70ec7c Specify Swift 5.1 syntax for targets 2020-08-30 16:27:17 -04:00
Max Howell
142d4bc111 Merge pull request #64 from mxcl/Path.source()
Add `Path.source()`
2020-08-19 13:41:50 -04:00
Max Howell
6461a550c6 Add Path.source() 2020-08-19 13:27:40 -04:00
Max Howell
8b90260517 Merge pull request #63 from mxcl/codecov-linux
Code coverage for linux
2020-08-01 19:45:07 -04:00
Max Howell
7924d20c8c Code coverage for linux 2020-08-01 15:59:54 -04:00
Max Howell
07007a5421 GHA CI Badge 2020-07-26 13:59:53 -04:00
Max Howell
8a217b3982 Merge pull request #62 from mxcl/sort-ls
ls() is sorted; Fixes #58
2020-07-26 13:58:44 -04:00
Max Howell
2b50909946 ls() is sorted; Fixes #58 2020-07-26 13:48:15 -04:00
Max Howell
baa6416208 Use GHA instead of Travis where possible 2020-07-26 13:39:10 -04:00
Max Howell
6e99825d9f Probably redundant tests, but why not 2020-02-09 14:52:49 -05:00
Max Howell
6e37bfde4d Update README.md 2020-02-09 14:29:28 -05:00
Max Howell
6e1eeb158a No fatal on linux Swift < 5 2020-01-25 14:00:59 -05:00
Max Howell
260196a27a CodeCoverage++ 2020-01-25 14:00:59 -05:00
Max Howell
0de9715b46 [ci skip] Update README.md 2020-01-25 12:05:58 -05:00
Max Howell
240d699986 Delete pushed version tag on failed deploy 2020-01-25 10:53:23 -05:00
Max Howell
b63b5746dc Delete pushed version tag on failed deploy 2020-01-24 12:14:03 -05:00
Max Howell
f062ed9ce3 Fix CI deploy 2020-01-24 12:01:34 -05:00
Max Howell
f1f7ee33b1 [ci skip] List all support Swift 2020-01-24 11:17:42 -05:00
Max Howell
694d04f18b Prepare 1.0.0 release 2020-01-24 11:03:07 -05:00
Max Howell
5636a7ac65 Update Swift Linux test versions 2020-01-18 12:10:32 -05:00
Max Howell
3e964833ff Fix README documentation for Finder 2020-01-18 12:10:32 -05:00
Max Howell
30122659a5 Update linux-tests; fail if warnings on travis
* Update linux-tests; fail if warnings on travis

* Fix warnings on Linux

* Typo

* Can’t test these on Linux
2019-08-18 16:52:24 -04:00
Max Howell
0ef50dff2e Finder is a iterable Sequence; .type -> .kind 2019-07-24 14:39:47 -04:00
Max Howell
dfad7367b7 Get out documentation %age up 2019-07-21 21:35:48 -04:00
Max Howell
af091cc1f0 Split this test so I can figure out CI failure 2019-07-21 18:58:50 -04:00
Max Howell
e5188bf93b Still not fixed 2019-07-21 18:58:49 -04:00
Max Howell
462a62920f Update Swifts in CI; Test Xcode 11 2019-07-21 18:58:49 -04:00
Max Howell
45b0b59a94 Better rx for tagged version detection on Travis 2019-07-21 17:37:10 -04:00
Max Howell
62073d584b Remove Entry since it is barely worthwhile sugar 2019-07-21 17:37:10 -04:00
Max Howell
621d1b0160 Remove @dynamicMember generally 2019-07-21 17:37:10 -04:00
Max Howell
d2bb2a1fdc Path.find() 2019-07-21 17:22:38 -04:00
Max Howell
38e98ee7fd Jazzy requires Xcode 10.2 now (per their docs) 2019-07-21 09:07:03 -04:00
24 changed files with 1132 additions and 374 deletions

3
.github/codecov.yml vendored
View File

@@ -1,2 +1,3 @@
ignore:
- Tests
- Tests/PathTests/etc.swift
- Tests/PathTests/TemporaryDirectory.swift

2
.github/jazzy.yml vendored
View File

@@ -3,7 +3,7 @@ custom_categories:
- name: Path
children:
- Path
- /(_:_:)
- Pathish
xcodebuild_arguments:
- UseModernBuildSystem=NO
output:

24
.github/workflows/cd.yml vendored Normal file
View File

@@ -0,0 +1,24 @@
name: CD
on:
release:
types: published
jobs:
docs:
runs-on: macos-latest
steps:
- uses: actions/checkout@v2
- uses: steven0351/publish-jazzy-docs@v1
with:
personal_access_token: ${{ secrets.PAT }}
config: .github/jazzy.yml
version: ${{ github.event.release.tag_name }}
history: false
pods:
runs-on: macos-latest
steps:
- uses: actions/checkout@v2
- run: pod trunk push --allow-warnings
env:
COCOAPODS_TRUNK_TOKEN: ${{ secrets.COCOAPODS_TRUNK_TOKEN }}
VERSION: ${{ github.event.release.tag_name }}

15
.github/workflows/checks.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
name: Checks
on:
push:
branches:
- master
paths:
- Sources/**
- Tests/**
jobs:
macOS:
runs-on: macos-latest
steps:
- uses: actions/checkout@v2
- run: swift --version
- run: swift test --parallel

83
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,83 @@
name: CI
on: pull_request
jobs:
smoke:
runs-on: macos-latest
steps:
- uses: technote-space/auto-cancel-redundant-job@v1
- uses: actions/checkout@v2
- run: swift test --generate-linuxmain
- run: git diff --exit-code
apple:
runs-on: macos-latest
strategy:
matrix:
destination:
- platform=iOS Simulator,OS=latest,name=iPhone 12
- platform=tvOS Simulator,OS=latest,name=Apple TV
# - platform=watchOS Simulator,OS=latest,name=Apple Watch Series 5 - 40mm
# ^^ coming with Xcode 12.5 which is not yet available on GHA
- platform=macOS
steps:
- uses: actions/checkout@v2
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: 12.4
- run: swift package generate-xcodeproj --enable-code-coverage
- uses: sersoft-gmbh/xcodebuild-action@v1
with:
project: Path.swift.xcodeproj
scheme: Path.swift-Package
destination: ${{ matrix.destination }}
action: test
- uses: codecov/codecov-action@v1
linux-swift-4:
name: linux (4.2)
runs-on: ubuntu-18.04
steps:
- uses: fwal/setup-swift@v1
with:
swift-version: 4.2
- uses: actions/checkout@v2
- run: swift test --parallel
linux:
runs-on: ubuntu-18.04
strategy:
matrix:
swift:
- '5.0'
- '5.1'
- '5.2'
- '5.3'
steps:
- uses: fwal/setup-swift@v1
with:
swift-version: ${{ matrix.swift }}
- uses: actions/checkout@v2
- run: swift test --parallel --enable-code-coverage
- name: Generate Coverage Report
run: |
sudo apt-get -qq update && sudo apt-get -qq install llvm-10
export b=$(swift build --show-bin-path) && llvm-cov-10 \
export -format lcov \
-instr-profile=$b/codecov/default.profdata \
--ignore-filename-regex='\.build/' \
$b/Path.swiftPackageTests.xctest \
> info.lcov
- uses: codecov/codecov-action@v1
with:
file: ./info.lcov
# code coverage fails with 5.4 for some reason
linux-swift-5-4:
name: linux (5.4)
runs-on: ubuntu-18.04
steps:
- uses: fwal/setup-swift@v1
with:
swift-version: 5.4
- uses: actions/checkout@v2
- run: swift test --parallel

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@
/*.xcodeproj
/build
/docs
/.swiftpm

View File

@@ -1,73 +1,37 @@
# only run for: merge commits, releases and pull-requests
if: type != push OR branch = master OR branch =~ /^\d+\.\d+(\.\d+)?(-\S*)?$/
# Travis does CD, GHA does CI
if: branch =~ /^deploy-\d+\.\d+\.\d+(-.*)?$/ OR branch =~ /^\d+\.\d+\.\d+(-.*)?$/
stages:
- name: pretest
- name: test
- name: deploy
if: branch =~ ^\d+\.\d+\.\d+$
if: branch =~ /^deploy-\d+\.\d+\.\d+(-.*)?$/
- name: publish
if: branch =~ /^\d+\.\d+\.\d+(-.*)?$/
os: osx
language: swift
osx_image: xcode10.1
xcode_project: Path.swift.xcodeproj
xcode_scheme: Path.swift-Package
jobs:
include:
- name: macOS / Swift 4.0.3
script: swift test --parallel -Xswiftc -swift-version -Xswiftc 4
- name: macOS / Swift 4.2.1
script: swift test --parallel
- name: macOS / Swift 5.0
osx_image: xcode10.2
script: swift test --parallel
- &xcodebuild
before_install: swift package generate-xcodeproj --enable-code-coverage
xcode_destination: platform=iOS Simulator,OS=latest,name=iPhone XS
name: iOS / Swift 4.2.1
after_success: bash <(curl -s https://codecov.io/bash)
- <<: *xcodebuild
xcode_destination: platform=tvOS Simulator,OS=latest,name=Apple TV
name: tvOS / Swift 4.2.1
- <<: *xcodebuild
name: watchOS / Swift 4.2.1
script: |
set -o pipefail
xcodebuild \
-project Path.swift.xcodeproj \
-scheme Path.swift-Package \
-destination 'platform=watchOS Simulator,OS=latest,name=Apple Watch Series 4 - 40mm' \
build | xcpretty
after_success: false
- &linux
env: SWIFT_VERSION=4.2.1
os: linux
name: Linux / Swift 4.2.1
language: generic
dist: trusty
sudo: false
install: eval "$(curl -sL https://swiftenv.fuller.li/install.sh)"
script: swift test --parallel
- <<: *linux
env: SWIFT_VERSION='5.0'
name: Linux / Swift 5.0.0
- stage: pretest
name: Check Linux tests are syncd
install: swift test --generate-linuxmain
script: git diff --exit-code
osx_image: xcode10.2
- stage: deploy
name: Deploy
osx_image: xcode11
env: HOMEBREW_NO_INSTALL_CLEANUP=1
install: brew install mxcl/made/swift-sh
git.depth: false
script:
- set -e
- export VERSION=$(echo $TRAVIS_TAG | cut -c 8-)
- git tag "$VERSION" --force
- git remote set-url origin "https://$GITHUB_TOKEN@github.com/$TRAVIS_REPO_SLUG.git"
- git push origin "$VERSION"
- swift sh <(curl https://raw.githubusercontent.com/mxcl/ops/master/deploy) publish-release
- git push origin :$TRAVIS_TAG
after_failure: |
export VERSION=$(echo $TRAVIS_TAG | cut -c 8-)
git push origin :$VERSION
- stage: publish
name: Jazzy
osx_image: xcode11
install: gem install jazzy
before_script: swift package generate-xcodeproj
script: |
@@ -83,11 +47,11 @@ jobs:
tags: true
- name: CocoaPods
osx_image: xcode10.2
install: |
brew install mxcl/made/swift-sh
curl -O https://raw.githubusercontent.com/mxcl/ops/master/deploy
chmod u+x deploy
env: HOMEBREW_NO_INSTALL_CLEANUP=1
osx_image: xcode11
install:
- brew install mxcl/made/swift-sh
- curl -O https://raw.githubusercontent.com/mxcl/ops/master/deploy
- chmod u+x deploy
before_script: ./deploy generate-podspec
script: pod trunk push
after_success: ./deploy publish-release

16
Path.swift.podspec Normal file
View File

@@ -0,0 +1,16 @@
Pod::Spec.new do |spec|
spec.name = "Path.swift"
spec.version = ENV['VERSION'] || "0.0.1"
spec.summary = "Delightful, robust, cross-platform and chainable file-pathing functions."
spec.homepage = "https://github.com/mxcl/Path.swift"
spec.license = "Unlicense"
spec.author = { "Max Howell" => "mxcl@me.com" }
spec.source = { :git => "https://github.com/mxcl/Path.swift.git", :tag => "#{spec.version}" }
spec.source_files = "Sources/*.swift"
spec.swift_versions = ['4.2', '5']
spec.module_name = 'Path'
spec.osx.deployment_target = '10.10'
spec.ios.deployment_target = '8.0'
spec.tvos.deployment_target = '9.0'
spec.watchos.deployment_target = '2.0'
end

223
README.md
View File

@@ -31,8 +31,11 @@ print(bar.isFile) // => true
let foo = try Path.root.join("foo").copy(into: Path.root.join("bar").mkdir())
print(foo) // => /bar/foo
print(foo.isFile) // => true
// ^^ the `into:` version will only copy *into* a directory, the `to:` version copies
// to a file at that path, thus you will not accidentally copy into directories you
// may not have realized existed.
// we support dynamic members (_use_sparingly_):
// we support dynamic-member-syntax when joining named static members, eg:
let prefs = Path.home.Library.Preferences // => /Users/mxcl/Library/Preferences
// a practical example: installing a helper executable
@@ -42,18 +45,13 @@ 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
Swift), we provide a thoughtful and comprehensive (yet concise) API.
# Support mxcl
# Sponsor @mxcl
Hi, Im Max Howell and I have written a lot of open source software, and
probably you already use some of it (Homebrew anyone?). I work full-time on
open source and its hard; currently I earn *less* than minimum wage. Please
help me continue my work, I appreciate it x
Hi, Im Max Howell and I have written a lot of open source software—generally
a good deal of my free time 👨🏻‍💻. Sponsorship helps me justify creating new open
source and maintaining it. Thank you.
<a href="https://www.patreon.com/mxcl">
<img src="https://c5.patreon.com/external/logo/become_a_patron_button@2x.png" width="160">
</a>
[Other donation/tipping options](http://mxcl.dev/#donate)
[Sponsor @mxcl].
# Handbook
@@ -75,30 +73,36 @@ try JSONEncoder().encode([Path.home, Path.home/"foo"])
]
```
However, often you want to encode relative paths:
Though we recommend encoding *relative* paths:
```swift
let encoder = JSONEncoder()
encoder.userInfo[.relativePath] = Path.home
encoder.encode([Path.home, Path.home/"foo"])
encoder.encode([Path.home, Path.home/"foo", Path.home/"../baz"])
```
```json
[
"",
"foo",
"../baz"
]
```
**Note** make sure you decode with this key set *also*, otherwise we `fatal`
(unless the paths are absolute obv.)
**Note** if you encode with this key set you *must* decode with the key
set also:
```swift
let decoder = JSONDecoder()
decoder.userInfo[.relativePath] = Path.home
decoder.decode(from: data)
try decoder.decode(from: data) // would throw if `.relativePath` not set
```
> ‡ If you are saving files to a system provided location, eg. Documents then
> the directory could change at Apples choice, or if say the user changes their
> username. Using relative paths also provides you with the flexibility in
> future to change where you are storing your files without hassle.
## Dynamic members
We support `@dynamicMemberLookup`:
@@ -107,10 +111,30 @@ We support `@dynamicMemberLookup`:
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*.
We only provide this for “starting” function, eg. `Path.home` or `Bundle.path`.
This is because we found in practice it was easy to write incorrect code, since
everything would compile if we allowed arbituary variables to take *any* named
property as valid syntax. What we have is what you want most of the time but
much less (potentially) dangerous (at runtime).
### Pathish
`Path`, and `DynamicPath` (the result of eg. `Path.root`) both conform to
`Pathish` which is a protocol that contains all pathing functions. Thus if
you create objects from a mixture of both you need to create generic
functions or convert any `DynamicPath`s to `Path` first:
```swift
let path1 = Path("/usr/lib")!
let path2 = Path.root.usr.bin
var paths = [Path]()
paths.append(path1) // fine
paths.append(path2) // error
paths.append(Path(path2)) // ok
```
This is inconvenient but as Swift stands theres nothing we can think of
that would help.
## Initializing from user-input
@@ -128,6 +152,28 @@ expect to be relative.
Our initializer is nameless to be consistent with the equivalent operation for
converting strings to `Int`, `Float` etc. in the standard library.
## Initializing from known strings
Theres no need to use the optional initializer in general if you have known
strings that you need to be paths:
```swift
let absolutePath = "/known/path"
let path1 = Path.root/absolutePath
let pathWithoutInitialSlash = "known/path"
let path2 = Path.root/pathWithoutInitialSlash
assert(path1 == path2)
let path3 = Path(absolutePath)! // at your options
assert(path2 == path3)
// be cautious:
let path4 = Path(pathWithoutInitialSlash)! // CRASH!
```
## Extensions
We have some extensions to Apple APIs:
@@ -146,27 +192,69 @@ try Bundle.main.resources.join("foo").copy(to: .home)
## Directory listings
We provide `ls()`, called because it behaves like the Terminal `ls` function,
the name thus implies its behavior, ie. that it is not recursive.
the name thus implies its behavior, ie. that it is not recursive and doesnt
list hidden files.
```swift
for entry in Path.home.ls() {
print(entry.path)
print(entry.kind) // .directory or .file
}
for entry in Path.home.ls() where entry.kind == .file {
for path in Path.home.ls() {
//
}
for entry in Path.home.ls() where entry.path.mtime > yesterday {
for path in Path.home.ls() where path.isFile {
//
}
for path in Path.home.ls() where path.mtime > yesterday {
//
}
let dirs = Path.home.ls().directories
// ^^ directories that *exist*
let files = Path.home.ls().files
// ^^ files that both *exist* and are *not* directories
let swiftFiles = Path.home.ls().files(withExtension: "swift")
let swiftFiles = Path.home.ls().files.filter{ $0.extension == "swift" }
let includingHiddenFiles = Path.home.ls(.a)
```
**Note** `ls()` does not throw, instead outputing a warning to the console if it
fails to list the directory. The rationale for this is weak, please open a
ticket for discussion.
We provide `find()` for recursive listing:
```swift
for path in Path.home.find() {
// descends all directories, and includes hidden files
// so it behaves the same as the terminal command `find`
}
```
It is configurable:
```swift
for path in Path.home.find().depth(max: 1).extension("swift").type(.file) {
//
}
```
It can be controlled with a closure syntax:
```swift
Path.home.find().depth(2...3).execute { path in
guard path.basename() != "foo.lock" else { return .abort }
if path.basename() == ".build", path.isDirectory { return .skip }
//
return .continue
}
```
Or get everything at once as an array:
```swift
let paths = Path.home.find().map(\.self)
```
# `Path.swift` is robust
@@ -175,8 +263,8 @@ 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.
`isExecutableFile`. `Path.swift` has done the leg-work for you so you can just
get on with it and not have to worry.
There is also some magic going on in Foundations filesystem APIs, which we look
for and ensure our API is deterministic, eg. [this test].
@@ -190,7 +278,8 @@ round them where necessary.
# Rules & Caveats
Paths are just string representations, there *might not* be a real file there.
Paths are just (normalized) string representations, there *might not* be a real
file there.
```swift
Path.home/"b" // => /Users/mxcl/b
@@ -198,7 +287,7 @@ Path.home/"b" // => /Users/mxcl/b
// joining multiple strings works as youd expect
Path.home/"b"/"c" // => /Users/mxcl/b/c
// joining multiple parts at a time is fine
// joining multiple parts simultaneously is fine
Path.home/"b/c" // => /Users/mxcl/b/c
// joining with absolute paths omits prefixed slash
@@ -208,6 +297,9 @@ Path.home/"/b" // => /Users/mxcl/b
Path.home.foo.bar.join("..") // => /Users/mxcl/foo
Path.home.foo.bar.join(".") // => /Users/mxcl/foo/bar
// though note that we provide `.parent`:
Path.home.foo.bar.parent // => /Users/mxcl/foo
// of course, feel free to join variables:
let b = "b"
let c = "c"
@@ -231,13 +323,13 @@ Path("/foo/bar/../baz") // => /foo/baz
// symlinks are not resolved
Path.root.bar.symlink(as: "foo")
Path("foo") // => /foo
Path.foo // => /foo
Path("/foo") // => /foo
Path.root.foo // => /foo
// unless you do it explicitly
try Path.foo.readlink() // => /bar
// `readlink` only resolves the *final* path component,
// thus use `realpath` if there are multiple symlinks
try Path.root.foo.readlink() // => /bar
// `readlink` only resolves the *final* path component,
// thus use `realpath` if there are multiple symlinks
```
*Path.swift* has the general policy that if the desired end result preexists,
@@ -247,7 +339,7 @@ then its a noop:
* If you try to make a directory and it already exists, we do nothing.
* If you call `readlink` on a non-symlink, we return `self`
However notably if you try to copy or move a file with specifying `overwrite`
However notably if you try to copy or move a file without 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.
@@ -258,23 +350,27 @@ for that as the check was deemed too expensive to be worthwhile.
equality check is required.
* There are several symlink paths on Mac that are typically automatically
resolved by Foundation, eg. `/private`, we attempt to do the same for
functions that you would expect it (notably `realpath`), we *do* the same for
`Path.init`, but *do not* if you are joining a path that ends up being one of
these paths, (eg. `Path.root.join("var/private')`).
functions that you would expect it (notably `realpath`), we *do* the same
for `Path.init`, but *do not* if you are joining a path that ends up being
one of these paths, (eg. `Path.root.join("var/private')`).
If a `Path` is a symlink but the destination of the link does not exist `exists`
returns `false`. This seems to be the correct thing to do since symlinks are
meant to be an abstraction for filesystems. To instead verify that there is
no filesystem entry there at all check if `kind` is `nil`.
no filesystem entry there at all check if `type` is `nil`.
## We do not provide change directory functionality
Changing directory is dangerous, you should *always* try to avoid it and thus
we dont even provide the method. If you are executing a sub-process then
use `Process.currentDirectoryURL`.
use `Process.currentDirectoryURL` to change *its* working directory when it
executes.
If you must then use `FileManager.changeCurrentDirectory`.
If you must change directory then use `FileManager.changeCurrentDirectory` as
early in your process as *possible*. Altering the global state of your apps
environment is fundamentally dangerous creating hard to debug issues that
you wont find for potentially *years*.
# I thought I should only use `URL`s?
@@ -283,8 +379,8 @@ Apple recommend this because they provide a magic translation for
file:///.file/id=6571367.15106761
Therefore, if you are not using this feature you are fine. If you have URLs the correct
way to get a `Path` is:
Therefore, if you are not using this feature you are fine. If you have URLs the
correct way to get a `Path` is:
```swift
if let path = Path(url: url) {
@@ -297,33 +393,39 @@ actual filesystem path, however we also check the URL has a `file` scheme first.
[file-refs]: https://developer.apple.com/documentation/foundation/nsurl/1408631-filereferenceurl
# In defense of our naming scheme
Chainable syntax demands short method names, thus we adopted the naming scheme
of the terminal, which is absolutely not very “Apple” when it comes to how they
design their APIs, however for users of the terminal (which *surely* is most
developers) it is snappy and familiar.
# Installation
SwiftPM:
```swift
package.append(.package(url: "https://github.com/mxcl/Path.swift.git", from: "0.13.0"))
package.append(
.package(url: "https://github.com/mxcl/Path.swift.git", from: "1.0.0")
)
package.targets.append(
.target(name: "Foo", dependencies: [
.product(name: "Path", package: "Path.swift")
])
)
```
CocoaPods:
```ruby
pod 'Path.swift', '~> 0.13'
pod 'Path.swift', '~> 1.0.0'
```
Carthage:
> Waiting on: [@Carthage#1945](https://github.com/Carthage/Carthage/pull/1945).
## Pre1.0 status
We are pre 1.0, thus we can change the API as we like, and we will (to the
pursuit of getting it *right*)! We will tag 1.0 as soon as possible.
### Get push notifications for new releases
https://mxcl.dev/canopy/
# Alternatives
* [Pathos](https://github.com/dduan/Pathos) by Daniel Duan
@@ -333,12 +435,13 @@ https://mxcl.dev/canopy/
[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%20%7C%205.0-orange.svg
[badge-languages]: https://img.shields.io/badge/swift-4.2%20%7C%205.x-orange.svg
[docs]: https://mxcl.dev/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
[badge-ci]: https://github.com/mxcl/Path.swift/workflows/Checks/badge.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
[Sponsor @mxcl]: https://github.com/sponsors/mxcl

View File

@@ -13,31 +13,31 @@ public extension Bundle {
Returns the path for the shared-frameworks directory in this bundle.
- Note: This is typically `ShareFrameworks`
*/
var sharedFrameworks: Path {
return sharedFrameworksPath.flatMap(Path.init) ?? defaultSharedFrameworksPath
var sharedFrameworks: DynamicPath {
return sharedFrameworksPath.flatMap(DynamicPath.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
var privateFrameworks: DynamicPath {
return privateFrameworksPath.flatMap(DynamicPath.init) ?? defaultSharedFrameworksPath
}
/// Returns the path for the resources directory in this bundle.
var resources: Path {
return resourcePath.flatMap(Path.init) ?? defaultResourcesPath
var resources: DynamicPath {
return resourcePath.flatMap(DynamicPath.init) ?? defaultResourcesPath
}
/// Returns the path for this bundle.
var path: Path {
return Path(string: bundlePath)
var path: DynamicPath {
return DynamicPath(string: bundlePath)
}
/// Returns the executable for this bundle, if there is one, not all bundles have one hence `Optional`.
var executable: Path? {
return executablePath.flatMap(Path.init)
var executable: DynamicPath? {
return executablePath.flatMap(DynamicPath.init)
}
}
@@ -45,14 +45,14 @@ public extension Bundle {
public extension String {
/// Initializes this `String` with the contents of the provided path.
@inlinable
init(contentsOf path: Path) throws {
init<P: Pathish>(contentsOf path: P) throws {
try self.init(contentsOfFile: path.string)
}
/// - Returns: `to` to allow chaining
@inlinable
@discardableResult
func write(to: Path, atomically: Bool = false, encoding: String.Encoding = .utf8) throws -> Path {
func write<P: Pathish>(to: P, atomically: Bool = false, encoding: String.Encoding = .utf8) throws -> P {
try write(toFile: to.string, atomically: atomically, encoding: encoding)
return to
}
@@ -62,14 +62,14 @@ public extension String {
public extension Data {
/// Initializes this `Data` with the contents of the provided path.
@inlinable
init(contentsOf path: Path) throws {
init<P: Pathish>(contentsOf path: P) throws {
try self.init(contentsOf: path.url)
}
/// - Returns: `to` to allow chaining
@inlinable
@discardableResult
func write(to: Path, atomically: Bool = false) throws -> Path {
func write<P: Pathish>(to: P, atomically: Bool = false) throws -> P {
let opts: NSData.WritingOptions
if atomically {
#if !os(Linux)
@@ -89,39 +89,39 @@ public extension Data {
public extension FileHandle {
/// Initializes this `FileHandle` for reading at the location of the provided path.
@inlinable
convenience init(forReadingAt path: Path) throws {
convenience init<P: Pathish>(forReadingAt path: P) 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 {
convenience init<P: Pathish>(forWritingAt path: P) 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 {
convenience init<P: Pathish>(forUpdatingAt path: P) throws {
try self.init(forUpdating: path.url)
}
}
internal extension Bundle {
var defaultSharedFrameworksPath: Path {
var defaultSharedFrameworksPath: DynamicPath {
#if os(macOS)
return path.join("Contents/Frameworks")
return path.Contents.Frameworks
#elseif os(Linux)
return path.join("lib")
return path.lib
#else
return path.join("Frameworks")
return path.Frameworks
#endif
}
var defaultResourcesPath: Path {
var defaultResourcesPath: DynamicPath {
#if os(macOS)
return path.join("Contents/Resources")
return path.Contents.Resources
#elseif os(Linux)
return path.join("share")
return path.share
#else
return path
#endif

View File

@@ -1,6 +1,6 @@
import Foundation
public extension Path {
public extension Pathish {
//MARK: Filesystem Attributes
/**
@@ -30,6 +30,29 @@ public extension Path {
}
}
/// The type of the entry.
/// - SeeAlso: `Path.EntryType`
@available(*, deprecated, message: "- SeeAlso: Path.type")
var kind: Path.EntryType? {
return type
}
/// The type of the entry.
/// - SeeAlso: `Path.EntryType`
var type: Path.EntryType? {
var buf = stat()
guard lstat(string, &buf) == 0 else {
return nil
}
if buf.st_mode & S_IFMT == S_IFLNK {
return .symlink
} else if buf.st_mode & S_IFMT == S_IFDIR {
return .directory
} else {
return .file
}
}
/**
Sets the files attributes using UNIX octal notation.
@@ -38,9 +61,11 @@ public extension Path {
@discardableResult
func chmod(_ octal: Int) throws -> Path {
try FileManager.default.setAttributes([.posixPermissions: octal], ofItemAtPath: string)
return self
return Path(self)
}
//MARK: Filesystem Locking
/**
Applies the macOS filesystem lock attribute.
- Note: If file is already locked, does nothing.
@@ -57,7 +82,7 @@ public extension Path {
try FileManager.default.setAttributes(attrs, ofItemAtPath: string)
}
#endif
return self
return Path(self)
}
/**
@@ -73,7 +98,7 @@ public extension Path {
do {
attrs = try FileManager.default.attributesOfItem(atPath: string)
} catch CocoaError.fileReadNoSuchFile {
return self
return Path(self)
}
let b = attrs[.immutable] as? Bool ?? false
if b {
@@ -81,24 +106,19 @@ public extension Path {
try FileManager.default.setAttributes(attrs, ofItemAtPath: string)
}
#endif
return self
}
enum Kind {
case file, symlink, directory
}
var kind: Kind? {
var buf = stat()
guard lstat(string, &buf) == 0 else {
return nil
}
if buf.st_mode & S_IFMT == S_IFLNK {
return .symlink
} else if buf.st_mode & S_IFMT == S_IFDIR {
return .directory
} else {
return .file
}
return Path(self)
}
}
/// The `extension` that provides `Kind`.
public extension Path {
/// A filesystem entrys kind, file, directory, symlink etc.
enum EntryType: CaseIterable {
/// The entry is a file.
case file
/// The entry is a symlink.
case symlink
/// The entry is a directory.
case directory
}
}

View File

@@ -30,11 +30,12 @@ extension Path: Codable {
let value = try decoder.singleValueContainer().decode(String.self)
if value.hasPrefix("/") {
string = value
} else {
guard let root = decoder.userInfo[.relativePath] as? Path else {
throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Path cannot decode a relative path if `userInfo[.relativePath]` not set to a Path object."))
}
} else if let root = decoder.userInfo[.relativePath] as? Path {
string = (root/value).string
} else if let root = decoder.userInfo[.relativePath] as? DynamicPath {
string = (root/value).string
} else {
throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Path cannot decode a relative path if `userInfo[.relativePath]` not set to a Path object."))
}
}
@@ -44,6 +45,8 @@ extension Path: Codable {
var container = encoder.singleValueContainer()
if let root = encoder.userInfo[.relativePath] as? Path {
try container.encode(relative(to: root))
} else if let root = encoder.userInfo[.relativePath] as? DynamicPath {
try container.encode(relative(to: root))
} else {
try container.encode(string)
}

View File

@@ -1,20 +1,33 @@
import Foundation
/// The `extension` that provides static properties that are common directories.
extension Path {
//MARK: Common Directories
/// Returns a `Path` containing `FileManager.default.currentDirectoryPath`.
public static var cwd: Path {
return Path(string: FileManager.default.currentDirectoryPath)
public static var cwd: DynamicPath {
return .init(string: FileManager.default.currentDirectoryPath)
}
/// Returns a `Path` representing the root path.
public static var root: Path {
return Path(string: "/")
public static var root: DynamicPath {
return .init(string: "/")
}
#if swift(>=5.3)
public static func source(for filePath: String = #filePath) -> (file: DynamicPath, directory: DynamicPath) {
let file = DynamicPath(string: filePath)
return (file: file, directory: .init(file.parent))
}
#else
public static func source(for filePath: String = #file) -> (file: DynamicPath, directory: DynamicPath) {
let file = DynamicPath(string: filePath)
return (file: file, directory: .init(file.parent))
}
#endif
/// Returns a `Path` representing the users home directory
public static var home: Path {
public static var home: DynamicPath {
let string: String
#if os(macOS)
if #available(OSX 10.12, *) {
@@ -25,30 +38,30 @@ extension Path {
#else
string = NSHomeDirectory()
#endif
return Path(string: string)
return .init(string: string)
}
/// Helper to allow search path and domain mask to be passed in.
private static func path(for searchPath: FileManager.SearchPathDirectory) -> Path {
private static func path(for searchPath: FileManager.SearchPathDirectory) -> DynamicPath {
#if os(Linux)
// the urls(for:in:) function is not implemented on Linux
//TODO strictly we should first try to use the provided binary tool
let foo = { ProcessInfo.processInfo.environment[$0].flatMap(Path.init) ?? $1 }
let foo = { ProcessInfo.processInfo.environment[$0].flatMap(Path.init).map(DynamicPath.init) ?? $1 }
switch searchPath {
case .documentDirectory:
return Path.home/"Documents"
return Path.home.Documents
case .applicationSupportDirectory:
return foo("XDG_DATA_HOME", Path.home/".local/share")
return foo("XDG_DATA_HOME", Path.home[dynamicMember: ".local/share"])
case .cachesDirectory:
return foo("XDG_CACHE_HOME", Path.home/".cache")
return foo("XDG_CACHE_HOME", Path.home[dynamicMember: ".cache"])
default:
fatalError()
}
#else
guard let pathString = FileManager.default.urls(for: searchPath, in: .userDomainMask).first?.path else { return defaultUrl(for: searchPath) }
return Path(string: pathString)
return DynamicPath(string: pathString)
#endif
}
@@ -57,7 +70,7 @@ extension Path {
- Note: There is no standard location for documents on Linux, thus we return `~/Documents`.
- Note: You should create a subdirectory before creating any files.
*/
public static var documents: Path {
public static var documents: DynamicPath {
return path(for: .documentDirectory)
}
@@ -66,7 +79,7 @@ extension Path {
- Note: On Linux this is `XDG_CACHE_HOME`.
- Note: You should create a subdirectory before creating any files.
*/
public static var caches: Path {
public static var caches: DynamicPath {
return path(for: .cachesDirectory)
}
@@ -75,20 +88,20 @@ extension Path {
- Note: On Linux is `XDG_DATA_HOME`.
- Note: You should create a subdirectory before creating any files.
*/
public static var applicationSupport: Path {
public static var applicationSupport: DynamicPath {
return path(for: .applicationSupportDirectory)
}
}
#if !os(Linux)
func defaultUrl(for searchPath: FileManager.SearchPathDirectory) -> Path {
func defaultUrl(for searchPath: FileManager.SearchPathDirectory) -> DynamicPath {
switch searchPath {
case .documentDirectory:
return Path.home/"Documents"
return Path.home.Documents
case .applicationSupportDirectory:
return Path.home/"Library/Application Support"
return Path.home.Library[dynamicMember: "Application Support"]
case .cachesDirectory:
return Path.home/"Library/Caches"
return Path.home.Library.Caches
default:
fatalError()
}

View File

@@ -3,7 +3,8 @@ import Foundation
import Glibc
#endif
public extension Path {
public extension Pathish {
//MARK: File Management
/**
@@ -24,17 +25,18 @@ public extension Path {
- SeeAlso: `copy(into:overwrite:)`
*/
@discardableResult
func copy(to: Path, overwrite: Bool = false) throws -> Path {
if overwrite, let tokind = to.kind, tokind != .directory, kind != .directory {
func copy<P: Pathish>(to: P, overwrite: Bool = false) throws -> Path {
if overwrite, let tokind = to.type, tokind != .directory, type != .directory {
try FileManager.default.removeItem(at: to.url)
}
#if os(Linux) && !swift(>=5.1) // check if fixed
if !overwrite, to.kind != nil {
#if os(Linux)
//NOTE doing manually due to inconsistency in Linux Foundation behavior
if !overwrite, to.type != nil {
throw CocoaError.error(.fileWriteFileExists)
}
#endif
try FileManager.default.copyItem(atPath: string, toPath: to.string)
return to
return Path(to)
}
/**
@@ -60,16 +62,17 @@ public extension Path {
- SeeAlso: `copy(to:overwrite:)`
*/
@discardableResult
func copy(into: Path, overwrite: Bool = false) throws -> Path {
if into.kind == nil {
func copy<P: Pathish>(into: P, overwrite: Bool = false) throws -> Path {
if into.type == nil {
try into.mkdir(.p)
}
let rv = into/basename()
if overwrite, let kind = rv.kind, kind != .directory {
if overwrite, let kind = rv.type, kind != .directory {
try FileManager.default.removeItem(at: rv.url)
}
#if os(Linux) && !swift(>=5.1) // check if fixed
if !overwrite, rv.kind != nil {
#if os(Linux)
//NOTE doing manually due to inconsistency in Linux Foundation behavior
if !overwrite, rv.type != nil {
throw CocoaError.error(.fileWriteFileExists)
}
#endif
@@ -94,12 +97,12 @@ public extension Path {
- SeeAlso: `move(into:overwrite:)`
*/
@discardableResult
func move(to: Path, overwrite: Bool = false) throws -> Path {
if overwrite, let kind = to.kind, kind != .directory {
func move<P: Pathish>(to: P, overwrite: Bool = false) throws -> Path {
if overwrite, let kind = to.type, kind != .directory {
try FileManager.default.removeItem(at: to.url)
}
try FileManager.default.moveItem(at: url, to: to.url)
return to
return Path(to)
}
/**
@@ -118,14 +121,14 @@ public extension Path {
- SeeAlso: `move(to:overwrite:)`
*/
@discardableResult
func move(into: Path, overwrite: Bool = false) throws -> Path {
switch into.kind {
func move<P: Pathish>(into: P, overwrite: Bool = false) throws -> Path {
switch into.type {
case nil:
try into.mkdir(.p)
fallthrough
case .directory?:
let rv = into/basename()
if overwrite, let rvkind = rv.kind, rvkind != .directory {
if overwrite, let rvkind = rv.type, rvkind != .directory {
try FileManager.default.removeItem(at: rv.url)
}
try FileManager.default.moveItem(at: url, to: rv.url)
@@ -147,19 +150,19 @@ public extension Path {
*/
@inlinable
func delete() throws {
if kind != nil {
if type != nil {
try FileManager.default.removeItem(at: url)
}
}
/**
Creates an empty file at this path or if the file exists, updates its modification time.
- Returns: `self` to allow chaining.
- Returns: A copy of `self` to allow chaining.
*/
@inlinable
@discardableResult
func touch() throws -> Path {
if kind == nil {
if type == nil {
guard FileManager.default.createFile(atPath: string, contents: nil) else {
throw CocoaError.error(.fileWriteUnknown)
}
@@ -172,7 +175,7 @@ public extension Path {
try FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: string)
#endif
}
return self
return Path(self)
}
/**
@@ -180,7 +183,7 @@ public extension Path {
- 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.
- Returns: A copy of `self` to allow chaining.
*/
@discardableResult
func mkdir(_ options: MakeDirectoryOptions? = nil) throws -> Path {
@@ -199,11 +202,11 @@ public extension Path {
throw error
#endif
}
return self
return Path(self)
}
/**
Renames the file at path.
Renames the file (basename only) at path.
Path.root.foo.bar.rename(to: "baz") // => /foo/baz
@@ -222,9 +225,9 @@ public extension Path {
- Note: If `self` does not exist, is **not** an error.
*/
@discardableResult
func symlink(as: Path) throws -> Path {
func symlink<P: Pathish>(as: P) throws -> Path {
try FileManager.default.createSymbolicLink(atPath: `as`.string, withDestinationPath: string)
return `as`
return Path(`as`)
}
/**
@@ -232,8 +235,8 @@ public extension Path {
- Note: If into does not exist, creates the directory with intermediate directories if necessary.
*/
@discardableResult
func symlink(into dir: Path) throws -> Path {
switch dir.kind {
func symlink<P: Pathish>(into dir: P) throws -> Path {
switch dir.type {
case nil, .symlink?:
try dir.mkdir(.p)
fallthrough

View File

@@ -11,3 +11,17 @@ extension Path: CustomDebugStringConvertible {
return "Path(\(string))"
}
}
extension DynamicPath: CustomStringConvertible {
/// Returns `Path.string`
public var description: String {
return string
}
}
extension DynamicPath: CustomDebugStringConvertible {
/// Returns eg. `Path(string: "/foo")`
public var debugDescription: String {
return "Path(\(string))"
}
}

View File

@@ -1,67 +1,203 @@
import Foundation
/**
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
public extension Path {
/// The builder for `Path.find()`
class Finder {
fileprivate init(path: Path) {
self.path = path
self.enumerator = FileManager.default.enumerator(atPath: path.string)
}
/// The `path` find operations operate on.
public let path: Path
private let enumerator: FileManager.DirectoryEnumerator!
/// The range of directory depths for which the find operation will return entries.
private(set) public var depth: ClosedRange<Int> = 1...Int.max {
didSet {
if depth.lowerBound < 0 {
depth = 0...depth.upperBound
}
}
}
/// The kinds of filesystem entries find operations will return.
public var types: Set<EntryType> {
return _types ?? Set(EntryType.allCases)
}
private var _types: Set<EntryType>?
/// The file extensions find operations will return. Files *and* directories unless you filter for `kinds`.
private(set) public var extensions: Set<String>?
}
/// The kind of this entry.
public let kind: Kind
/// The path of this entry.
public let path: Path
}
public extension Path {
//MARK: Directory Listings
extension Path.Finder: Sequence, IteratorProtocol {
public func next() -> Path? {
guard let enumerator = enumerator else {
return nil
}
while let relativePath = enumerator.nextObject() as? String {
let path = self.path/relativePath
#if !os(Linux) || swift(>=5.0)
if enumerator.level > depth.upperBound {
enumerator.skipDescendants()
continue
}
if enumerator.level < depth.lowerBound {
continue
}
#endif
if let type = path.type, !types.contains(type) { continue }
if let exts = extensions, !exts.contains(path.extension) { continue }
return path
}
return nil
}
public typealias Element = Path
}
public extension Path.Finder {
/// A max depth of `0` returns only the path we are searching, `1` is that directorys listing.
func depth(max maxDepth: Int) -> Path.Finder {
#if os(Linux) && !swift(>=5.0)
fputs("warning: depth not implemented for Swift < 5\n", stderr)
#endif
depth = Swift.min(maxDepth, depth.lowerBound)...maxDepth
return self
}
/// A min depth of `0` also returns the path we are searching, `1` is that directorys listing. Default is `1` thus not returning ourself.
func depth(min minDepth: Int) -> Path.Finder {
#if os(Linux) && !swift(>=5.0)
fputs("warning: depth not implemented for Swift < 5\n", stderr)
#endif
depth = minDepth...Swift.max(depth.upperBound, minDepth)
return self
}
/// A max depth of `0` returns only the path we are searching, `1` is that directorys listing.
/// A min depth of `0` also returns the path we are searching, `1` is that directorys listing. Default is `1` thus not returning ourself.
func depth(_ rng: Range<Int>) -> Path.Finder {
#if os(Linux) && !swift(>=5.0)
fputs("warning: depth not implemented for Swift < 5\n", stderr)
#endif
depth = rng.lowerBound...(rng.upperBound - 1)
return self
}
/// A max depth of `0` returns only the path we are searching, `1` is that directorys listing.
/// A min depth of `0` also returns the path we are searching, `1` is that directorys listing. Default is `1` thus not returning ourself.
func depth(_ rng: ClosedRange<Int>) -> Path.Finder {
#if os(Linux) && !swift(>=5.0)
fputs("warning: depth not implemented for Swift < 5\n", stderr)
#endif
depth = rng
return self
}
/// Multiple calls will configure the Finder with multiple kinds.
func type(_ type: Path.EntryType) -> Path.Finder {
_types = _types ?? []
_types!.insert(type)
return self
}
/// Multiple calls will configure the Finder with for multiple extensions
func `extension`(_ ext: String) -> Path.Finder {
extensions = extensions ?? []
extensions!.insert(ext)
return self
}
/// The return type for `Path.Finder`
enum ControlFlow {
/// Stop enumerating this directory, return to the parent.
case skip
/// Stop enumerating all together.
case abort
/// Keep going.
case `continue`
}
/// Enumerate, one file at a time.
func execute(_ closure: (Path) throws -> ControlFlow) rethrows {
while let path = next() {
switch try closure(path) {
case .skip:
#if !os(Linux) || swift(>=5.0)
enumerator.skipDescendants()
#else
fputs("warning: skip is not implemented for Swift < 5.0\n", stderr)
#endif
case .abort:
return
case .continue:
continue
}
}
}
}
public extension Pathish {
//MARK: Directory Listing
/**
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`.
- Important: `includeHiddenFiles` does not work on Linux
Same as the `ls` command output is shallow and unsorted.
- Note: as per `ls`, by default we do *not* return hidden files. Specify `.a` for hidden files.
- Parameter options: Configure the listing.
- Important: On Linux the listing is always `ls -a`
*/
func ls(includeHiddenFiles: Bool = true) throws -> [Entry] {
var opts = FileManager.DirectoryEnumerationOptions()
#if !os(Linux)
if !includeHiddenFiles {
opts.insert(.skipsHiddenFiles)
func ls(_ options: ListDirectoryOptions? = nil) -> [Path] {
guard let urls = try? FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) else {
fputs("warning: could not list: \(self)\n", stderr)
return []
}
#endif
let paths = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: opts)
func convert(url: URL) -> Entry? {
return urls.compactMap { url in
guard let path = Path(url.path) else { return nil }
return Entry(kind: path.isDirectory ? .directory : .file, path: path)
}
return paths.compactMap(convert)
if options != .a, path.basename().hasPrefix(".") { return nil }
// ^^ we dont use the Foundation `skipHiddenFiles` because it considers weird things hidden and we are mirroring `ls`
return path
}.sorted()
}
/// Recursively find files under this path. If the path is a file, no files will be found.
func find() -> Path.Finder {
return .init(path: Path(self))
}
}
/// 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.
/// Convenience functions for the arrays of `Path`
public extension Array where Element == Path {
/// Filters the list of entries to be a list of Paths that are directories. Symlinks to directories are not returned.
var directories: [Path] {
return compactMap {
$0.kind == .directory ? $0.path : nil
return filter {
$0.isDirectory
}
}
/// Filters the list of entries to be a list of Paths that are files.
/// Filters the list of entries to be a list of Paths that exist and are *not* directories. Thus expect symlinks, etc.
/// - Note: symlinks that point to files that do not exist are *not* returned.
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] {
return compactMap {
$0.kind == .file && $0.path.extension == ext ? $0.path : nil
return filter {
switch $0.type {
case .none, .directory?:
return false
case .file?, .symlink?:
return true
}
}
}
}
/// Options for `Path.ls(_:)`
public enum ListDirectoryOptions {
/// Creates intermediary directories; works the same as `mkdir -p`.
case a
}

View File

@@ -5,12 +5,14 @@ import func Glibc.access
import Darwin
#endif
public extension Path {
public extension Pathish {
//MARK: Filesystem Properties
/**
- Returns: `true` if the path represents an actual filesystem entry.
- Note: If `self` is a symlink the return value represents the destination.
- Note: If `self` is a symlink the return value represents the destination, thus if the destination doesnt exist this returns `false` even if the symlink exists.
- Note: To determine if a symlink exists, use `.type`.
*/
var exists: Bool {
return FileManager.default.fileExists(atPath: string)

View File

@@ -32,11 +32,16 @@ let _realpath = Glibc.realpath
let p1 = Path.root.usr.bin.ls // => /usr/bin/ls
- Note: A `Path` does not necessarily represent an actual filesystem entry.
*/
However we only provide this support off of the static members like `root` due
to the anti-pattern where Path.swift suddenly feels like Javascript otherwise.
@dynamicMemberLookup
public struct Path: Equatable, Hashable, Comparable {
- Note: A `Path` does not necessarily represent an actual filesystem entry.
- SeeAlso: `Pathish` for most methods you will use on `Path` instances.
*/
public struct Path: Pathish {
/// The normalized string representation of the underlying filesystem path
public let string: String
init(string: String) {
assert(string.first == "/")
@@ -70,11 +75,11 @@ public struct Path: Equatable, Hashable, Comparable {
ifExists(withPrefix: "/var/automount", removeFirst: 2)
ifExists(withPrefix: "/private", removeFirst: 1)
#endif
self.string = join_(prefix: "/", pathComponents: pathComponents)
string = join_(prefix: "/", pathComponents: pathComponents)
case "~":
if description == "~" {
self = Path.home
string = Path.home.string
return
}
let tilded: String
@@ -96,7 +101,7 @@ public struct Path: Equatable, Hashable, Comparable {
#endif
}
pathComponents.remove(at: 0)
self.string = join_(prefix: tilded, pathComponents: pathComponents)
string = join_(prefix: tilded, pathComponents: pathComponents)
default:
return nil
@@ -124,20 +129,17 @@ public struct Path: Equatable, Hashable, Comparable {
// ^^ works even if the url is a file-reference url
}
/// :nodoc:
public subscript(dynamicMember addendum: String) -> Path {
//NOTE its possible for the string to be anything if we are invoked via
// explicit subscript thus we use our fully sanitized `join` function
return Path(string: join_(prefix: string, appending: addendum))
/// Converts anything that is `Pathish` to a `Path`
public init<P: Pathish>(_ path: P) {
string = path.string
}
}
//MARK: Properties
/// The underlying filesystem path
public let string: String
public extension Pathish {
//MARK: Filesystem Representation
/// Returns a `URL` representing this file path.
public var url: URL {
var url: URL {
return URL(fileURLWithPath: string)
}
@@ -147,7 +149,7 @@ public struct Path: Equatable, Hashable, Comparable {
- SeeAlso: https://developer.apple.com/documentation/foundation/nsurl/1408631-filereferenceurl
- Important: On Linux returns an file scheme NSURL for this path string.
*/
public var fileReferenceURL: NSURL? {
var fileReferenceURL: NSURL? {
#if !os(Linux)
// https://bugs.swift.org/browse/SR-2728
return (url as NSURL).perform(#selector(NSURL.fileReferenceURL))?.takeUnretainedValue() as? NSURL
@@ -164,8 +166,9 @@ public struct Path: Equatable, Hashable, Comparable {
- Note: always returns a valid path, `Path.root.parent` *is* `Path.root`.
*/
public var parent: Path {
var parent: Path {
let index = string.lastIndex(of: "/")!
guard index != string.indices.startIndex else { return Path(string: "/") }
let substr = string[string.indices.startIndex..<index]
return Path(string: String(substr))
}
@@ -174,10 +177,10 @@ public struct Path: Equatable, Hashable, Comparable {
Returns the filename extension of this path.
- Remark: If there is no extension returns "".
- Remark: If the filename ends with any number of ".", returns "".
- Note: We special case eg. `foo.tar.gz`.
- Note: We special case eg. `foo.tar.gz`there are a limited number of these specializations, feel free to PR more.
*/
@inlinable
public var `extension`: String {
var `extension`: String {
//FIXME efficiency
switch true {
case string.hasSuffix(".tar.gz"):
@@ -201,14 +204,14 @@ public struct Path: Equatable, Hashable, Comparable {
/**
Splits the string representation on the directory separator.
- Important: The first element is always "/" to be consistent with `NSString.pathComponents`.
- Important: `NSString.pathComponents` will always return an initial `/` in its array for absolute paths to indicate that the path was absolute, we dont do this because we are *always* absolute paths.
*/
@inlinable
public var components: [String] {
return ["/"] + string.split(separator: "/").map(String.init)
var components: [String] {
return string.split(separator: "/").map(String.init)
}
//MARK: Pathing
//MARK:- Pathing
/**
Joins a path and a string to produce a new path.
@@ -225,7 +228,7 @@ public struct Path: Equatable, Hashable, Comparable {
- Returns: A new joined path.
- SeeAlso: `Path./(_:_:)`
*/
public func join<S>(_ addendum: S) -> Path where S: StringProtocol {
func join<S>(_ addendum: S) -> Path where S: StringProtocol {
return Path(string: join_(prefix: string, appending: addendum))
}
@@ -246,7 +249,7 @@ public struct Path: Equatable, Hashable, Comparable {
- SeeAlso: `join(_:)`
*/
@inlinable
public static func /<S>(lhs: Path, rhs: S) -> Path where S: StringProtocol {
static func /<S>(lhs: Self, rhs: S) -> Path where S: StringProtocol {
return lhs.join(rhs)
}
@@ -257,7 +260,7 @@ public struct Path: Equatable, Hashable, Comparable {
- Parameter base: The base to which we calculate the relative path.
- ToDo: Another variant that returns `nil` if result would start with `..`
*/
public func relative(to base: Path) -> String {
func relative<P: Pathish>(to base: P) -> String {
// Split the two paths into their components.
// FIXME: The is needs to be optimized to avoid unncessary copying.
let pathComps = (string as NSString).pathComponents
@@ -296,7 +299,7 @@ public struct Path: Equatable, Hashable, Comparable {
- Returns: A string that is the filenames basename.
- Parameter dropExtension: If `true` returns the basename without its file extension.
*/
public func basename(dropExtension: Bool = false) -> String {
func basename(dropExtension: Bool = false) -> String {
var lastPathComponent: Substring {
let slash = string.lastIndex(of: "/")!
let index = string.index(after: slash)
@@ -321,27 +324,25 @@ public struct Path: Equatable, Hashable, Comparable {
If the path represents an actual entry that is a symlink, returns the symlinks
absolute destination.
- Important: This is not exhaustive, the resulting path may still contain
symlink.
- Important: The path will only be different if the last path component is a
symlink, any symlinks in prior components are not resolved.
- Important: This is not exhaustive, the resulting path may still contain a symlink.
- Important: The path will only be different if the last path component is a symlink, any symlinks in prior components are not resolved.
- Note: If file exists but isnt a symlink, returns `self`.
- Note: If symlink destination does not exist, is **not** an error.
*/
public func readlink() throws -> Path {
func readlink() throws -> Path {
do {
let rv = try FileManager.default.destinationOfSymbolicLink(atPath: string)
return Path(rv) ?? parent/rv
} catch CocoaError.fileReadUnknown {
// file is not symlink, return `self`
assert(exists)
return self
return Path(string: string)
} catch {
#if os(Linux)
// ugh: Swift on Linux
let nsError = error as NSError
if nsError.domain == NSCocoaErrorDomain, nsError.code == CocoaError.fileReadUnknown.rawValue, exists {
return self
return Path(self)
}
#endif
throw error
@@ -349,7 +350,7 @@ public struct Path: Equatable, Hashable, Comparable {
}
/// Recursively resolves symlinks in this path.
public func realpath() throws -> Path {
func realpath() throws -> Path {
guard let rv = _realpath(string, nil) else { throw CocoaError.error(.fileNoSuchFile) }
defer { free(rv) }
guard let rvv = String(validatingUTF8: rv) else { throw CocoaError.error(.fileReadUnknownStringEncoding) }
@@ -367,7 +368,7 @@ public struct Path: Equatable, Hashable, Comparable {
/// Returns the locale-aware sort order for the two paths.
/// :nodoc:
@inlinable
public static func <(lhs: Path, rhs: Path) -> Bool {
static func <(lhs: Self, rhs: Self) -> Bool {
return lhs.string.compare(rhs.string, locale: .current) == .orderedAscending
}
}
@@ -406,3 +407,27 @@ private func join_<S>(prefix: String, pathComponents: S) -> String where S: Sequ
}
return rv
}
/// A path that supports arbituary dot notation, eg. `Path.root.usr.bin`
@dynamicMemberLookup
public struct DynamicPath: Pathish {
/// The normalized string representation of the underlying filesystem path
public let string: String
init(string: String) {
assert(string.hasPrefix("/"))
self.string = string
}
/// Converts a `Path` to a `DynamicPath`
public init<P: Pathish>(_ path: P) {
string = path.string
}
/// :nodoc:
public subscript(dynamicMember addendum: String) -> DynamicPath {
//NOTE its possible for the string to be anything if we are invoked via
// explicit subscript thus we use our fully sanitized `join` function
return DynamicPath(string: join_(prefix: string, appending: addendum))
}
}

6
Sources/Pathish.swift Normal file
View File

@@ -0,0 +1,6 @@
/// A type that represents a filesystem path, if you conform your type
/// to `Pathish` it is your responsibility to ensure the string is correctly normalized
public protocol Pathish: Hashable, Comparable {
/// The normalized string representation of the underlying filesystem path
var string: String { get }
}

View File

@@ -0,0 +1,229 @@
import XCTest
import Path
extension PathTests {
func testFindMaxDepth1() throws {
try Path.mktemp { tmpdir in
try tmpdir.a.touch()
try tmpdir.b.touch()
try tmpdir.c.mkdir().join("e").touch()
do {
let finder = tmpdir.find().depth(max: 1)
XCTAssertEqual(finder.depth, 1...1)
#if !os(Linux) || swift(>=5)
XCTAssertEqual(Set(finder), Set([tmpdir.a, tmpdir.b, tmpdir.c].map(Path.init)))
#endif
}
do {
let finder = tmpdir.find().depth(max: 0)
XCTAssertEqual(finder.depth, 0...0)
#if !os(Linux) || swift(>=5)
XCTAssertEqual(Set(finder), Set())
#endif
}
}
}
func testFindMaxDepth2() throws {
try Path.mktemp { tmpdir in
try tmpdir.a.touch()
try tmpdir.b.mkdir().join("c").touch()
try tmpdir.b.d.mkdir().join("e").touch()
do {
let finder = tmpdir.find().depth(max: 2)
XCTAssertEqual(finder.depth, 1...2)
#if !os(Linux) || swift(>=5)
XCTAssertEqual(
Set(finder),
Set([tmpdir.a, tmpdir.b, tmpdir.b.d, tmpdir.b.c].map(Path.init)))
#endif
}
do {
let finder = tmpdir.find().depth(max: 3)
XCTAssertEqual(finder.depth, 1...3)
#if !os(Linux) || swift(>=5)
XCTAssertEqual(
Set(finder),
Set([tmpdir.a, tmpdir.b, tmpdir.b.d, tmpdir.b.c, tmpdir.b.d.e].map(Path.init)))
#endif
}
}
}
func testFindMinDepth() throws {
try Path.mktemp { tmpdir in
try tmpdir.a.touch()
try tmpdir.b.mkdir().join("c").touch()
try tmpdir.b.d.mkdir().join("e").touch()
try tmpdir.b.d.f.mkdir().join("g").touch()
do {
let finder = tmpdir.find().depth(min: 2)
XCTAssertEqual(finder.depth, 2...Int.max)
#if !os(Linux) || swift(>=5)
XCTAssertEqual(
Set(finder),
Set([tmpdir.b.c, tmpdir.b.d, tmpdir.b.d.e, tmpdir.b.d.f, tmpdir.b.d.f.g].map(Path.init)),
relativeTo: tmpdir)
#endif
}
}
}
func testFindDepth0() throws {
try Path.mktemp { tmpdir in
try tmpdir.a.touch()
try tmpdir.b.mkdir().join("c").touch()
try tmpdir.b.d.mkdir().join("e").touch()
try tmpdir.b.d.f.mkdir().join("g").touch()
do {
let finder = tmpdir.find().depth(min: 0)
XCTAssertEqual(finder.depth, 0...Int.max)
#if !os(Linux) || swift(>=5)
XCTAssertEqual(
Set(finder),
Set([tmpdir.a, tmpdir.b, tmpdir.b.c, tmpdir.b.d, tmpdir.b.d.e, tmpdir.b.d.f, tmpdir.b.d.f.g].map(Path.init)),
relativeTo: tmpdir)
#endif
}
do {
// this should work, even though its weird
let finder = tmpdir.find().depth(min: -1)
XCTAssertEqual(finder.depth, 0...Int.max)
#if !os(Linux) || swift(>=5)
XCTAssertEqual(
Set(finder),
Set([tmpdir.a, tmpdir.b, tmpdir.b.c, tmpdir.b.d, tmpdir.b.d.e, tmpdir.b.d.f, tmpdir.b.d.f.g].map(Path.init)),
relativeTo: tmpdir)
#endif
}
}
}
func testFindDepthRange() throws {
try Path.mktemp { tmpdir in
try tmpdir.a.touch()
try tmpdir.b.mkdir().join("c").touch()
try tmpdir.b.d.mkdir().join("e").touch()
try tmpdir.b.d.f.mkdir().join("g").touch()
do {
let range = 2...3
let finder = tmpdir.find().depth(range)
XCTAssertEqual(finder.depth, range)
#if !os(Linux) || swift(>=5)
XCTAssertEqual(
Set(finder),
Set([tmpdir.b.d, tmpdir.b.c, tmpdir.b.d.e, tmpdir.b.d.f].map(Path.init)),
relativeTo: tmpdir)
#endif
}
do {
let range = 2..<4
let finder = tmpdir.find().depth(range)
XCTAssertEqual(finder.depth, 2...3)
#if !os(Linux) || swift(>=5)
XCTAssertEqual(
Set(finder),
Set([tmpdir.b.d, tmpdir.b.c, tmpdir.b.d.e, tmpdir.b.d.f].map(Path.init)),
relativeTo: tmpdir)
#endif
}
}
}
func testFindExtension() throws {
try Path.mktemp { tmpdir in
try tmpdir.join("foo.json").touch()
try tmpdir.join("bar.txt").touch()
XCTAssertEqual(
Set(tmpdir.find().extension("json")),
[tmpdir.join("foo.json")])
XCTAssertEqual(
Set(tmpdir.find().extension("txt").extension("json")),
[tmpdir.join("foo.json"), tmpdir.join("bar.txt")])
}
}
//NOTE this is how iterators work, so we have a test to validate that behavior
func testFindCallingExecuteTwice() throws {
try Path.mktemp { tmpdir in
try tmpdir.join("foo.json").touch()
try tmpdir.join("bar.txt").touch()
let finder = tmpdir.find()
XCTAssertEqual(finder.map{ $0 }.count, 2)
XCTAssertEqual(finder.map{ $0 }.count, 0)
}
}
func testFindExecute() throws {
try Path.mktemp { tmpdir in
try tmpdir.a.touch()
try tmpdir.b.mkdir().join("c").touch()
try tmpdir.b.d.mkdir().join("e").touch()
try tmpdir.b.d.f.mkdir().join("g").touch()
#if !os(Linux) || swift(>=5)
do {
var rv = Set<Path>()
tmpdir.find().execute {
switch $0 {
case Path(tmpdir.b.d): return .skip
default:
rv.insert($0)
return .continue
}
}
XCTAssertEqual(rv, Set([tmpdir.a, tmpdir.b, tmpdir.b.c].map(Path.init)))
}
#endif
do {
var x = 0
tmpdir.find().execute { _ in
x += 1
return .abort
}
XCTAssertEqual(x, 1)
}
}
}
func testFindTypes() throws {
try Path.mktemp { tmpdir in
try tmpdir.foo.mkdir()
try tmpdir.bar.touch()
XCTAssertEqual(
Set(tmpdir.find().type(.file)),
[tmpdir.join("bar")])
XCTAssertEqual(
Set(tmpdir.find().type(.directory)),
[tmpdir.join("foo")])
XCTAssertEqual(
Set(tmpdir.find().type(.file).type(.directory)),
Set(["foo", "bar"].map(tmpdir.join)))
}
}
func testLsOnNonexistentDirectoryReturnsEmptyArray() throws {
try Path.mktemp { tmpdir in
XCTAssertEqual(tmpdir.a.ls(), [])
}
}
func testFindOnNonexistentDirectoryHasNoContent() throws {
try Path.mktemp { tmpdir in
XCTAssertNil(tmpdir.a.find().next())
}
}
}

View File

@@ -1,4 +1,6 @@
@testable import Path
import func XCTest.XCTAssertEqual
import Foundation
import XCTest
class PathTests: XCTestCase {
@@ -18,32 +20,31 @@ class PathTests: XCTestCase {
func testEnumeration() throws {
let tmpdir_ = try TemporaryDirectory()
let tmpdir = tmpdir_.path
try tmpdir.a.mkdir().c.touch()
try tmpdir.join("a").mkdir().join("c").touch()
try tmpdir.join("b.swift").touch()
try tmpdir.c.touch()
try tmpdir.join(".d").mkdir().e.touch()
try tmpdir.join("c").touch()
try tmpdir.join(".d").mkdir().join("e").touch()
var paths = Set<String>()
let lsrv = try tmpdir.ls()
let lsrv = tmpdir.ls(.a)
var dirs = 0
for entry in lsrv {
if entry.kind == .directory {
for path in lsrv {
if path.isDirectory {
dirs += 1
}
paths.insert(entry.path.basename())
paths.insert(path.basename())
}
XCTAssertEqual(dirs, 2)
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(["b.swift"], Set(lsrv.files.filter{ $0.extension == "swift" }.map{ $0.relative(to: tmpdir) }))
XCTAssertEqual(["c"], Set(lsrv.files.filter{ $0.extension == "" }.map{ $0.relative(to: tmpdir) }))
XCTAssertEqual(paths, ["a", "b.swift", "c", ".d"])
}
func testEnumerationSkippingHiddenFiles() throws {
#if !os(Linux)
let tmpdir_ = try TemporaryDirectory()
let tmpdir = tmpdir_.path
try tmpdir.join("a").mkdir().join("c").touch()
@@ -53,20 +54,19 @@ class PathTests: XCTestCase {
var paths = Set<String>()
var dirs = 0
for entry in try tmpdir.ls(includeHiddenFiles: false) {
if entry.kind == .directory {
for path in tmpdir.ls() {
if path.isDirectory {
dirs += 1
}
paths.insert(entry.path.basename())
paths.insert(path.basename())
}
XCTAssertEqual(dirs, 1)
XCTAssertEqual(paths, ["a", "b", "c"])
#endif
}
func testRelativeTo() {
XCTAssertEqual((Path.root/"tmp/foo").relative(to: .root/"tmp"), "foo")
XCTAssertEqual((Path.root/"tmp/foo/bar").relative(to: .root/"tmp/baz"), "../foo/bar")
XCTAssertEqual((Path.root.tmp.foo).relative(to: Path.root/"tmp"), "foo")
XCTAssertEqual((Path.root.tmp.foo.bar).relative(to: Path.root/"tmp/baz"), "../foo/bar")
}
func testExists() throws {
@@ -76,10 +76,10 @@ class PathTests: XCTestCase {
try Path.mktemp { tmpdir in
XCTAssertTrue(tmpdir.exists)
XCTAssertFalse(try tmpdir.bar.symlink(as: tmpdir.foo).exists)
XCTAssertTrue(tmpdir.foo.kind == .symlink)
XCTAssertTrue(tmpdir.foo.type == .symlink)
XCTAssertTrue(try tmpdir.bar.touch().symlink(as: tmpdir.baz).exists)
XCTAssertTrue(tmpdir.bar.kind == .file)
XCTAssertTrue(tmpdir.kind == .directory)
XCTAssertTrue(tmpdir.bar.type == .file)
XCTAssertTrue(tmpdir.type == .directory)
}
}
@@ -105,7 +105,7 @@ class PathTests: XCTestCase {
}
func testMktemp() throws {
var path: Path!
var path: DynamicPath!
try Path.mktemp {
path = $0
XCTAssert(path.isDirectory)
@@ -133,7 +133,7 @@ class PathTests: XCTestCase {
}
func testCodable() throws {
let input = [Path.root.foo, Path.root.foo.bar, Path.root]
let input = [Path.root.foo, Path.root.foo.bar, Path.root].map(Path.init)
XCTAssertEqual(try JSONDecoder().decode([Path].self, from: try JSONEncoder().encode(input)), input)
}
@@ -143,18 +143,24 @@ class PathTests: XCTestCase {
Path.root,
root,
root.bar
]
].map(Path.init)
let encoder = JSONEncoder()
encoder.userInfo[.relativePath] = root
let data = try encoder.encode(input)
XCTAssertEqual(try JSONSerialization.jsonObject(with: data) as? [String], ["..", "", "bar"])
func test<P: Pathish>(relativePath: P, line: UInt = #line) throws {
encoder.userInfo[.relativePath] = relativePath
let data = try encoder.encode(input)
let decoder = JSONDecoder()
XCTAssertThrowsError(try decoder.decode([Path].self, from: data))
decoder.userInfo[.relativePath] = root
XCTAssertEqual(try decoder.decode([Path].self, from: data), input)
XCTAssertEqual(try JSONSerialization.jsonObject(with: data) as? [String], ["..", "", "bar"], line: line)
let decoder = JSONDecoder()
XCTAssertThrowsError(try decoder.decode([Path].self, from: data), line: line)
decoder.userInfo[.relativePath] = relativePath
XCTAssertEqual(try decoder.decode([Path].self, from: data), input, line: line)
}
try test(relativePath: root) // DynamicPath
try test(relativePath: Path(root)) // Path
}
func testJoin() {
@@ -186,6 +192,14 @@ class PathTests: XCTestCase {
XCTAssertEqual(Path.root/"a/foo"/"../../../bar", Path.root/"bar")
}
func testParent() {
XCTAssertEqual(Path("/root/boot")!.parent.string, "/root")
XCTAssertEqual(Path("/root/boot")!.parent.parent.string, "/")
XCTAssertEqual(Path("/root/boot")!.parent.parent.parent.string, "/")
XCTAssertEqual(Path("/root")!.parent.string, "/")
XCTAssertEqual(Path("/root")!.parent.parent.string, "/")
}
func testDynamicMember() {
XCTAssertEqual(Path.root.Documents, Path.root/"Documents")
@@ -193,7 +207,7 @@ class PathTests: XCTestCase {
XCTAssertEqual(a.Documents, Path.home/"foo/Documents")
// verify use of the dynamic-member-subscript works according to our rules
XCTAssertEqual(Path.home[dynamicMember: "../~foo"].string, "\(Path.home.parent.string)/~foo")
XCTAssertEqual(Path.home[dynamicMember: "../~foo"].string, Path(Path.home).parent.join("~foo").string)
}
func testCopyTo() throws {
@@ -204,7 +218,9 @@ class PathTests: XCTestCase {
XCTAssertThrowsError(try root.foo.copy(to: root.bar))
try root.foo.copy(to: root.bar, overwrite: true)
}
}
func testCopyToExistingDirectoryFails() throws {
// test copy errors if directory exists at destination, even with overwrite
try Path.mktemp { root in
try root.foo.touch()
@@ -297,6 +313,13 @@ class PathTests: XCTestCase {
XCTAssertEqual(Path.root.string, "/")
XCTAssertEqual(Path.home.string, NSHomeDirectory())
XCTAssertEqual(Path.documents.string, NSHomeDirectory() + "/Documents")
#if swift(>=5.3)
let filePath = Path(#filePath)!
#else
let filePath = Path(#file)!
#endif
XCTAssertEqual(Path.source().file, filePath)
XCTAssertEqual(Path.source().directory, filePath.parent)
#if !os(Linux)
XCTAssertEqual(Path.caches.string, NSHomeDirectory() + "/Library/Caches")
XCTAssertEqual(Path.cwd.string, FileManager.default.currentDirectoryPath)
@@ -311,6 +334,8 @@ class PathTests: XCTestCase {
func testStringConvertibles() {
XCTAssertEqual(Path.root.description, "/")
XCTAssertEqual(Path.root.debugDescription, "Path(/)")
XCTAssertEqual(Path(Path.root).description, "/")
XCTAssertEqual(Path(Path.root).debugDescription, "Path(/)")
}
func testFilesystemAttributes() throws {
@@ -363,17 +388,13 @@ class PathTests: XCTestCase {
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
let foo = try tmpdir.foo.touch()
#if !os(Linux)
XCTAssertGreaterThan(foo.ctime?.timeIntervalSince1970.rounded(.down) ?? 0, now1) //FIXME flakey
#endif
XCTAssertGreaterThan(foo.mtime?.timeIntervalSince1970.rounded(.down) ?? 0, now1) //FIXME flakey
XCTAssertNil(tmpdir.void.mtime)
XCTAssertNil(tmpdir.void.ctime)
@@ -391,23 +412,26 @@ class PathTests: XCTestCase {
// regression test: can delete a symlink that points to a non-existent file
let bar5 = try tmpdir.bar4.symlink(as: tmpdir.bar5)
XCTAssertEqual(bar5.kind, .symlink)
XCTAssertEqual(bar5.type, .symlink)
XCTAssertFalse(bar5.exists)
XCTAssertNoThrow(try bar5.delete())
XCTAssertEqual(bar5.kind, nil)
XCTAssertEqual(bar5.type, nil)
// test that deleting a symlink *only* deletes the symlink
let bar7 = try tmpdir.bar6.touch().symlink(as: tmpdir.bar7)
XCTAssertEqual(bar7.kind, .symlink)
XCTAssertEqual(bar7.type, .symlink)
XCTAssertTrue(bar7.exists)
XCTAssertNoThrow(try bar7.delete())
XCTAssertEqual(bar7.kind, nil)
XCTAssertEqual(bar7.type, nil)
XCTAssertEqual(tmpdir.bar6.type, .file)
// for code-coverage
XCTAssertEqual(tmpdir.bar6.kind, .file)
}
}
func testRelativeCodable() throws {
let path = Path.home.foo
let path = Path(Path.home.foo)
let encoder = JSONEncoder()
encoder.userInfo[.relativePath] = Path.home
let data = try encoder.encode([path])
@@ -415,13 +439,15 @@ class PathTests: XCTestCase {
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])
XCTAssertEqual(try decoder.decode([Path].self, from: data), [Path(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)!
try Path.mktemp { tmpdir -> Void in
guard let bndl = Bundle(path: tmpdir.string) else {
return XCTFail("Couldnt make Bundle for \(tmpdir)")
}
XCTAssertEqual(bndl.path, tmpdir)
XCTAssertEqual(bndl.sharedFrameworks, tmpdir.SharedFrameworks)
XCTAssertEqual(bndl.privateFrameworks, tmpdir.Frameworks)
@@ -432,7 +458,7 @@ class PathTests: XCTestCase {
#if os(macOS)
XCTAssertEqual(bndl.defaultSharedFrameworksPath, tmpdir.Contents.Frameworks)
XCTAssertEqual(bndl.defaultResourcesPath, tmpdir.Contents.Resources)
#elseif os(tvOS) || os(iOS)
#elseif os(tvOS) || os(iOS) || os(watchOS)
XCTAssertEqual(bndl.defaultSharedFrameworksPath, tmpdir.Frameworks)
XCTAssertEqual(bndl.defaultResourcesPath, tmpdir)
#else
@@ -484,8 +510,19 @@ class PathTests: XCTestCase {
func testTouchThrowsIfCannotWrite() throws {
try Path.mktemp { tmpdir in
print(try FileManager.default.attributesOfItem(atPath: tmpdir.string)[.posixPermissions])
//FIXME fails in Docker image (only)
try tmpdir.chmod(0o000)
let attrs = try FileManager.default.attributesOfItem(atPath: tmpdir.string)
XCTAssertEqual(attrs[.posixPermissions] as? Int, 0)
print(attrs[.posixPermissions])
XCTAssertThrowsError(try tmpdir.bar.touch())
XCTAssertFalse(tmpdir.bar.exists)
}
}
@@ -513,7 +550,7 @@ class PathTests: XCTestCase {
let foo = try tmpdir.foo.touch()
let bar = try tmpdir.bar.mkdir()
XCTAssertThrowsError(try foo.symlink(as: bar))
XCTAssert(try foo.symlink(as: bar.foo).isSymlink)
XCTAssert(try foo.symlink(as: bar/"foo").isSymlink)
}
}
@@ -568,8 +605,8 @@ class PathTests: XCTestCase {
try Path.mktemp { tmpdir in
let foo = try tmpdir.foo.mkdir()
try foo.bar.mkdir().fuz.touch()
let baz = try foo.symlink(as: tmpdir.baz)
try foo.join("bar").mkdir().join("fuz").touch()
let baz = DynamicPath(try foo.symlink(as: tmpdir.baz))
XCTAssert(baz.isSymlink)
XCTAssert(baz.bar.isDirectory)
XCTAssertEqual(baz.bar.join("..").string, "\(tmpdir)/baz")
@@ -582,7 +619,7 @@ class PathTests: XCTestCase {
try Path.mktemp { tmpdir in
let b = try tmpdir.a.b.mkdir(.p)
let c = try tmpdir.a.c.touch()
let e = try c.symlink(as: b.e)
let e = try c.symlink(as: b/"e")
let f = try e.symlink(as: tmpdir.f)
XCTAssertEqual(try f.readlink(), e)
XCTAssertEqual(try f.realpath(), c)
@@ -615,8 +652,8 @@ class PathTests: XCTestCase {
}
func testPathComponents() throws {
XCTAssertEqual(Path.root.foo.bar.components, ["/", "foo", "bar"])
XCTAssertEqual(Path.root.components, ["/"])
XCTAssertEqual(Path.root.foo.bar.components, ["foo", "bar"])
XCTAssertEqual(Path.root.components, [])
}
func testFlatMap() throws {
@@ -633,9 +670,15 @@ class PathTests: XCTestCase {
try Path.mktemp { tmpdir in
let foo = try tmpdir.foo.touch()
let bar = try foo.symlink(as: tmpdir.bar)
XCTAssertEqual(tmpdir.kind, .directory)
XCTAssertEqual(foo.kind, .file)
XCTAssertEqual(bar.kind, .symlink)
XCTAssertEqual(tmpdir.type, .directory)
XCTAssertEqual(foo.type, .file)
XCTAssertEqual(bar.type, .symlink)
}
}
func testOptionalInitializer() throws {
XCTAssertNil(Path(""))
XCTAssertNil(Path("./foo"))
XCTAssertEqual(Path("/foo"), Path.root.foo)
}
}

View File

@@ -3,7 +3,7 @@ import Foundation
class TemporaryDirectory {
let url: URL
var path: Path { return Path(string: url.path) }
var path: DynamicPath { return DynamicPath(Path(string: url.path)) }
/**
Creates a new temporary directory.
@@ -51,7 +51,7 @@ class TemporaryDirectory {
}
extension Path {
static func mktemp<T>(body: (Path) throws -> T) throws -> T {
static func mktemp<T>(body: (DynamicPath) throws -> T) throws -> T {
let tmp = try TemporaryDirectory()
return try body(tmp.path)
}

View File

@@ -13,6 +13,7 @@ extension PathTests {
("testConcatenation", testConcatenation),
("testCopyInto", testCopyInto),
("testCopyTo", testCopyTo),
("testCopyToExistingDirectoryFails", testCopyToExistingDirectoryFails),
("testDataExtensions", testDataExtensions),
("testDelete", testDelete),
("testDynamicMember", testDynamicMember),
@@ -23,17 +24,30 @@ extension PathTests {
("testFileHandleExtensions", testFileHandleExtensions),
("testFileReference", testFileReference),
("testFilesystemAttributes", testFilesystemAttributes),
("testFindCallingExecuteTwice", testFindCallingExecuteTwice),
("testFindDepth0", testFindDepth0),
("testFindDepthRange", testFindDepthRange),
("testFindExecute", testFindExecute),
("testFindExtension", testFindExtension),
("testFindMaxDepth1", testFindMaxDepth1),
("testFindMaxDepth2", testFindMaxDepth2),
("testFindMinDepth", testFindMinDepth),
("testFindOnNonexistentDirectoryHasNoContent", testFindOnNonexistentDirectoryHasNoContent),
("testFindTypes", testFindTypes),
("testFlatMap", testFlatMap),
("testInitializerForRelativePath", testInitializerForRelativePath),
("testIsDirectory", testIsDirectory),
("testJoin", testJoin),
("testKind", testKind),
("testLock", testLock),
("testLsOnNonexistentDirectoryReturnsEmptyArray", testLsOnNonexistentDirectoryReturnsEmptyArray),
("testMkpathIfExists", testMkpathIfExists),
("testMktemp", testMktemp),
("testMoveInto", testMoveInto),
("testMoveTo", testMoveTo),
("testNoUndesiredSymlinkResolution", testNoUndesiredSymlinkResolution),
("testOptionalInitializer", testOptionalInitializer),
("testParent", testParent),
("testPathComponents", testPathComponents),
("testReadlinkOnFileReturnsSelf", testReadlinkOnFileReturnsSelf),
("testReadlinkOnNonExistantFileThrows", testReadlinkOnNonExistantFileThrows),

43
Tests/PathTests/etc.swift Normal file
View File

@@ -0,0 +1,43 @@
import XCTest
import Path
#if swift(>=5.3)
func XCTAssertEqual<P: Pathish>(_ set1: Set<Path>, _ set2: Set<Path>, _ message: @autoclosure () -> String = "", file: StaticString = #filePath, line: UInt = #line, relativeTo: P) {
logic(set1, set2, relativeTo: relativeTo) {
XCTFail($0, file: file, line: line)
}
}
#else
func XCTAssertEqual<P: Pathish>(_ set1: Set<Path>, _ set2: Set<Path>, _ message: @autoclosure () -> String = "", file: StaticString = #file, line: UInt = #line, relativeTo: P) {
logic(set1, set2, relativeTo: relativeTo) {
XCTFail($0, file: file, line: line)
}
}
#endif
private func logic<P: Pathish>(_ set1: Set<Path>, _ set2: Set<Path>, relativeTo: P, fail: (String) -> Void) {
if set1 != set2 {
let cvt: (Path) -> String = { $0.relative(to: relativeTo) }
let out1 = set1.map(cvt).sorted()
let out2 = set1.map(cvt).sorted()
fail("Set(\(out1)) is not equal to Set(\(out2))")
}
}
#if swift(>=5.3)
func XCTAssertEqual<P: Pathish, Q: Pathish>(_ p: P, _ q: Q, file: StaticString = #filePath, line: UInt = #line) {
XCTAssertEqual(p.string, q.string, file: file, line: line)
}
func XCTAssertEqual<P: Pathish, Q: Pathish>(_ p: P?, _ q: Q?, file: StaticString = #filePath, line: UInt = #line) {
XCTAssertEqual(p?.string, q?.string, file: file, line: line)
}
#else
func XCTAssertEqual<P: Pathish, Q: Pathish>(_ p: P, _ q: Q, file: StaticString = #file, line: UInt = #line) {
XCTAssertEqual(p.string, q.string, file: file, line: line)
}
func XCTAssertEqual<P: Pathish, Q: Pathish>(_ p: P?, _ q: Q?, file: StaticString = #file, line: UInt = #line) {
XCTAssertEqual(p?.string, q?.string, file: file, line: line)
}
#endif