├── .github ├── changelog-configuration.yml └── workflows │ ├── conventional-pr.yml │ ├── file-system.yml │ └── release.yml ├── .gitignore ├── .mise ├── tasks │ ├── build │ ├── build-linux │ ├── build-spm │ ├── cache │ ├── lint │ ├── lint-fix │ ├── test │ ├── test-linux │ └── test-spm └── utilities │ ├── root_dir.sh │ └── setup.sh ├── .swiftformat ├── .swiftlint.yml ├── .xcode-version ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── FileSystem │ ├── AsyncSequence+Extras.swift │ └── FileSystem.swift ├── FileSystemTesting │ └── FileSystemTestingTrait.swift └── Glob │ ├── GlobSearch.swift │ ├── InvalidPattern.swift │ ├── Pattern+Match.swift │ ├── Pattern+Options.swift │ ├── Pattern+Parser.swift │ ├── Pattern.swift │ └── Unicode.GeneralCategory+Helpers.swift ├── Tests ├── FileSystemTestingTests │ └── FileSystemTestingTraitTests.swift ├── FileSystemTests │ └── FileSystemTests.swift └── GlobTests │ ├── PatternTests.swift │ └── TestHelpers │ ├── XCTAssertMatches.swift │ └── XCTExpectFailure.swift ├── cliff.toml ├── mise.toml └── renovate.json /.github/changelog-configuration.yml: -------------------------------------------------------------------------------- 1 | { 2 | "categories": 3 | [ 4 | { "title": "#### Changed", "labels": ["changelog:changed"] }, 5 | { "title": "#### Added", "labels": ["changelog:added"] }, 6 | { "title": "#### Fixed", "labels": ["changelog:fixed"] }, 7 | { 8 | "title": "#### Dependency updates", 9 | "labels": ["changelog:updated-dependencies"], 10 | }, 11 | ], 12 | "pr_template": "- ${{TITLE}} by [@${{AUTHOR}}](https://github.com/${{AUTHOR}})", 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/conventional-pr.yml: -------------------------------------------------------------------------------- 1 | name: conventional-pr 2 | on: 3 | pull_request: 4 | paths: 5 | - "Sources/**" 6 | - "Package.swift" 7 | - "Package.Package.resolved" 8 | branches: 9 | - main 10 | types: 11 | - opened 12 | - edited 13 | - synchronize 14 | 15 | jobs: 16 | lint-pr: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: CondeNast/conventional-pull-request-action@v0.2.0 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | -------------------------------------------------------------------------------- /.github/workflows/file-system.yml: -------------------------------------------------------------------------------- 1 | name: FileSystem 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: {} 8 | 9 | env: 10 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 11 | MISE_EXPERIMENTAL: "1" 12 | 13 | concurrency: 14 | group: file-system-${{ github.head_ref }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | build: 19 | name: "Release build on macOS" 20 | runs-on: macos-15 21 | steps: 22 | - uses: actions/checkout@v4 23 | - run: sudo xcode-select -s /Applications/Xcode_16.3.app 24 | - uses: jdx/mise-action@v2 25 | - name: Run 26 | run: mise run build-spm 27 | 28 | build-linux: 29 | name: "Release build on Linux" 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: actions/checkout@v4 33 | - uses: jdx/mise-action@v2 34 | - name: Run 35 | run: mise run build-linux 36 | 37 | test: 38 | name: "Test on macOS" 39 | runs-on: macos-15 40 | steps: 41 | - uses: actions/checkout@v4 42 | - run: sudo xcode-select -s /Applications/Xcode_16.3.app 43 | - uses: jdx/mise-action@v2 44 | - name: Run 45 | run: mise run test-spm 46 | 47 | test_linux: 48 | name: "Test on Linux" 49 | runs-on: ubuntu-latest 50 | steps: 51 | - uses: actions/checkout@v4 52 | - uses: jdx/mise-action@v2 53 | - name: Run 54 | run: mise run test-linux 55 | 56 | lint: 57 | name: Lint 58 | runs-on: macos-15 59 | steps: 60 | - uses: actions/checkout@v4 61 | - run: sudo xcode-select -s /Applications/Xcode_16.3.app 62 | - uses: jdx/mise-action@v2 63 | - name: Run 64 | run: mise run lint 65 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | inputs: 9 | version: 10 | description: "The version to release" 11 | type: string 12 | 13 | permissions: 14 | contents: write 15 | pull-requests: read 16 | statuses: write 17 | packages: write 18 | 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | 22 | jobs: 23 | release: 24 | name: Release 25 | runs-on: "ubuntu-latest" 26 | timeout-minutes: 15 27 | if: "!startsWith(github.event.head_commit.message, '[Release]')" 28 | steps: 29 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 30 | with: 31 | fetch-depth: 0 32 | - uses: jdx/mise-action@v2 33 | with: 34 | experimental: true 35 | - name: Check if there are releasable changes 36 | id: is-releasable 37 | run: | 38 | bumped_output=$(git cliff --bump) 39 | changelog_content=$(cat CHANGELOG.md) 40 | 41 | bumped_hash=$(echo -n "$bumped_output" | shasum -a 256 | awk '{print $1}') 42 | changelog_hash=$(echo -n "$changelog_content" | shasum -a 256 | awk '{print $1}') 43 | 44 | if [ "$bumped_hash" != "$changelog_hash" ]; then 45 | echo "should-release=true" >> $GITHUB_ENV 46 | else 47 | echo "should-release=false" >> $GITHUB_ENV 48 | fi 49 | 50 | - name: Get next version 51 | id: next-version 52 | if: env.should-release == 'true' 53 | run: echo "NEXT_VERSION=$(git cliff --bumped-version)" >> "$GITHUB_OUTPUT" 54 | - name: Get release notes 55 | id: release-notes 56 | if: env.should-release == 'true' 57 | run: | 58 | echo "RELEASE_NOTES<> "$GITHUB_OUTPUT" 59 | git cliff --unreleased >> "$GITHUB_OUTPUT" 60 | echo "EOF" >> "$GITHUB_OUTPUT" 61 | - name: Update CHANGELOG.md 62 | if: env.should-release == 'true' 63 | run: git cliff --bump -o CHANGELOG.md 64 | - name: Commit changes 65 | id: auto-commit-action 66 | uses: stefanzweifel/git-auto-commit-action@v5 67 | if: env.should-release == 'true' 68 | env: 69 | GITHUB_TOKEN: ${{ secrets.TUIST_FILE_SYSTEM_RELEASE_TOKEN }} 70 | with: 71 | commit_options: "--allow-empty" 72 | tagging_message: ${{ steps.next-version.outputs.NEXT_VERSION }} 73 | skip_dirty_check: true 74 | commit_message: "[Release] FileSystem ${{ steps.next-version.outputs.NEXT_VERSION }}" 75 | - name: Create GitHub Release 76 | uses: softprops/action-gh-release@v2 77 | if: env.should-release == 'true' 78 | with: 79 | draft: false 80 | repository: tuist/FileSystem 81 | name: ${{ steps.next-version.outputs.NEXT_VERSION }} 82 | tag_name: ${{ steps.next-version.outputs.NEXT_VERSION }} 83 | body: ${{ steps.release-notes.outputs.RELEASE_NOTES }} 84 | target_commitish: ${{ steps.auto-commit-action.outputs.commit_hash }} 85 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | 10 | *.xcodeproj/ 11 | *.xcworkspace 12 | Tuist/Dependencies/SwiftPackageManager 13 | 14 | Derived/ 15 | .swiftpm/ 16 | node_modules/ 17 | docs/.vitepress/cache/ 18 | docs/.vitepress/dist -------------------------------------------------------------------------------- /.mise/tasks/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # mise description="Build the project using Tuist" 3 | set -euo pipefail 4 | 5 | tuist build --path $MISE_PROJECT_ROOT -------------------------------------------------------------------------------- /.mise/tasks/build-linux: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # mise description="Builds the project using Swift Package Manager in Linux" 3 | set -euo pipefail 4 | 5 | CONTAINER_RUNTIME=$(command -v podman || command -v docker) 6 | 7 | if [ -z "$CONTAINER_RUNTIME" ]; then 8 | echo "Neither podman nor docker is available. Please install one to proceed." 9 | exit 1 10 | fi 11 | 12 | $CONTAINER_RUNTIME run --rm \ 13 | --volume "$MISE_PROJECT_ROOT:/package" \ 14 | --workdir "/package" \ 15 | swift:6.1.0 \ 16 | /bin/bash -c \ 17 | "swift build --configuration release --build-path ./.build/linux" 18 | -------------------------------------------------------------------------------- /.mise/tasks/build-spm: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # mise description="Build the project using Swift Package Manager" 3 | set -euo pipefail 4 | 5 | swift build --package-path $MISE_PROJECT_ROOT --configuration release -------------------------------------------------------------------------------- /.mise/tasks/cache: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # mise description="Cache the dependencies using Tuist" 3 | set -euo pipefail 4 | 5 | tuist cache --path $MISE_PROJECT_ROOT -------------------------------------------------------------------------------- /.mise/tasks/lint: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # mise description="Lint the project using SwiftLint and SwiftFormat" 3 | set -euo pipefail 4 | 5 | swiftformat $MISE_PROJECT_ROOT --lint 6 | swiftlint lint --quiet --config $MISE_PROJECT_ROOT/.swiftlint.yml $MISE_PROJECT_ROOT/Sources -------------------------------------------------------------------------------- /.mise/tasks/lint-fix: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # mise description="Lint the project using SwiftLint and SwiftFormat fixing the issues" 3 | set -euo pipefail 4 | 5 | swiftformat $MISE_PROJECT_ROOT 6 | swiftlint lint --fix --quiet --config $MISE_PROJECT_ROOT/.swiftlint.yml $MISE_PROJECT_ROOT/Sources -------------------------------------------------------------------------------- /.mise/tasks/test: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # mise description="Test the project using Tuist" 3 | set -euo pipefail 4 | 5 | tuist test FileSystem --path $MISE_PROJECT_ROOT -------------------------------------------------------------------------------- /.mise/tasks/test-linux: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # mise description="Tests the project using Swift Package Manager in Linux" 3 | set -euo pipefail 4 | 5 | CONTAINER_RUNTIME=$(command -v podman || command -v docker) 6 | 7 | if [ -z "$CONTAINER_RUNTIME" ]; then 8 | echo "Neither podman nor docker is available. Please install one to proceed." 9 | exit 1 10 | fi 11 | 12 | $CONTAINER_RUNTIME run --rm \ 13 | --volume "$MISE_PROJECT_ROOT:/package" \ 14 | --workdir "/package" \ 15 | swift:6.1.0 \ 16 | /bin/bash -c \ 17 | "swift test" 18 | -------------------------------------------------------------------------------- /.mise/tasks/test-spm: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # mise description="Test the project using Swift Package Manager" 3 | 4 | set -euo pipefail 5 | 6 | swift test --package-path $MISE_PROJECT_ROOT -------------------------------------------------------------------------------- /.mise/utilities/root_dir.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 6 | ROOT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" 7 | echo $ROOT_DIR -------------------------------------------------------------------------------- /.mise/utilities/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | format_section() { 4 | echo -e "\033[1;36m=== ${1} ===\033[0m" 5 | } 6 | 7 | format_subsection() { 8 | echo -e "\033[1;32m${1}\033[0m" 9 | } 10 | 11 | format_success() { 12 | echo -e "\033[1;32m${1}\033[0m" 13 | } 14 | 15 | format_error() { 16 | echo -e "\033[1;31m${1}\033[0m" 17 | } -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | # file options 2 | 3 | --symlinks ignore 4 | --disable hoistAwait 5 | --disable hoistTry 6 | --swiftversion 5.7 7 | 8 | # format options 9 | 10 | --allman false 11 | --binarygrouping 4,8 12 | --closingparen balanced 13 | --commas always 14 | --comments indent 15 | --decimalgrouping 3,6 16 | --elseposition same-line 17 | --empty void 18 | --exponentcase lowercase 19 | --exponentgrouping disabled 20 | --extensionacl on-declarations 21 | --fractiongrouping disabled 22 | --header strip 23 | --hexgrouping 4,8 24 | --hexliteralcase uppercase 25 | --ifdef indent 26 | --indent 4 27 | --indentcase false 28 | --importgrouping testable-bottom 29 | --linebreaks lf 30 | --octalgrouping 4,8 31 | --operatorfunc spaced 32 | --patternlet hoist 33 | --ranges spaced 34 | --self remove 35 | --semicolons inline 36 | --stripunusedargs always 37 | --trimwhitespace always 38 | --maxwidth 130 39 | --wraparguments before-first 40 | --wrapcollections before-first 41 | --wrapconditions after-first 42 | --wrapparameters before-first -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - trailing_whitespace 3 | - trailing_comma 4 | - nesting 5 | - cyclomatic_complexity 6 | - file_length 7 | - todo 8 | - function_parameter_count 9 | - opening_brace 10 | - line_length 11 | identifier_name: 12 | min_length: 13 | error: 1 14 | warning: 1 15 | max_length: 16 | warning: 60 17 | error: 80 18 | inclusive_language: 19 | override_allowed_terms: 20 | - masterKey 21 | type_name: 22 | min_length: 23 | error: 1 24 | warning: 1 -------------------------------------------------------------------------------- /.xcode-version: -------------------------------------------------------------------------------- 1 | 15.0.1 -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.10.0] - 2025-05-26 9 | ### Details 10 | #### Feat 11 | - Expose Glob target as a package product by @yhkaplan in [#130](https://github.com/tuist/FileSystem/pull/130) 12 | 13 | ## New Contributors 14 | * @yhkaplan made their first contribution in [#130](https://github.com/tuist/FileSystem/pull/130) 15 | ## [0.9.2] - 2025-05-20 16 | ### Details 17 | #### Chore 18 | - Update dependency tuist to v4.50.2 by @renovate[bot] in [#129](https://github.com/tuist/FileSystem/pull/129) 19 | 20 | ## [0.9.1] - 2025-05-15 21 | ### Details 22 | #### Chore 23 | - Update dependency tuist to v4.50.1 by @renovate[bot] in [#128](https://github.com/tuist/FileSystem/pull/128) 24 | 25 | ## [0.9.0] - 2025-05-15 26 | ### Details 27 | #### Feat 28 | - Move FileSystemTestingTrait to a new library product FileSystemTesting by @fortmarek in [#126](https://github.com/tuist/FileSystem/pull/126) 29 | 30 | ## [0.8.0] - 2025-05-13 31 | ### Details 32 | #### Feat 33 | - Add a Swift Testing trait to create and scope a temporary directory to a test or suite lifecycle by @pepicrft 34 | - Add a Swift Testing trait to create and scope a temporary directory to a test or suite lifecycle by @pepicrft 35 | 36 | ## [0.7.18] - 2025-05-12 37 | ### Details 38 | #### Chore 39 | - Update dependency apple/swift-nio to from: "2.83.0" by @renovate[bot] 40 | 41 | ## [0.7.17] - 2025-05-12 42 | ### Details 43 | #### Chore 44 | - Update dependency tuist to v4.49.1 by @renovate[bot] 45 | 46 | ## New Contributors 47 | * @danieleformichelli made their first contribution in [#122](https://github.com/tuist/FileSystem/pull/122) 48 | ## [0.7.16] - 2025-04-25 49 | ### Details 50 | #### Chore 51 | - Update dependency tuist to v4.48.2 by @renovate[bot] in [#119](https://github.com/tuist/FileSystem/pull/119) 52 | 53 | ## [0.7.15] - 2025-04-25 54 | ### Details 55 | #### Fix 56 | - Infinite loop caused by symbolic links pointing to ancestor directories by @monchote in [#120](https://github.com/tuist/FileSystem/pull/120) 57 | 58 | ## New Contributors 59 | * @monchote made their first contribution in [#120](https://github.com/tuist/FileSystem/pull/120) 60 | ## [0.7.14] - 2025-04-22 61 | ### Details 62 | #### Chore 63 | - Update dependency apple/swift-nio to from: "2.82.0" by @renovate[bot] in [#118](https://github.com/tuist/FileSystem/pull/118) 64 | 65 | ## [0.7.13] - 2025-04-17 66 | ### Details 67 | #### Fix 68 | - Resolve relative symbolic link by @fortmarek in [#116](https://github.com/tuist/FileSystem/pull/116) 69 | 70 | ## [0.7.12] - 2025-04-15 71 | ### Details 72 | #### Chore 73 | - Update dependency tuist to v4.48.1 by @renovate[bot] in [#114](https://github.com/tuist/FileSystem/pull/114) 74 | 75 | ## [0.7.11] - 2025-04-15 76 | ### Details 77 | #### Fix 78 | - Ignore .gitkeep results when globbing by @dogo in [#115](https://github.com/tuist/FileSystem/pull/115) 79 | 80 | ## New Contributors 81 | * @dogo made their first contribution in [#115](https://github.com/tuist/FileSystem/pull/115) 82 | ## [0.7.10] - 2025-04-04 83 | ### Details 84 | #### Chore 85 | - Update dependency tuist to v4.46.1 by @renovate[bot] in [#113](https://github.com/tuist/FileSystem/pull/113) 86 | 87 | ## [0.7.9] - 2025-03-24 88 | ### Details 89 | #### Fix 90 | - Crash from long strings when building in Debug by @waltflanagan in [#112](https://github.com/tuist/FileSystem/pull/112) 91 | 92 | ## New Contributors 93 | * @waltflanagan made their first contribution in [#112](https://github.com/tuist/FileSystem/pull/112) 94 | ## [0.7.8] - 2025-03-19 95 | ### Details 96 | #### Chore 97 | - Update dependency apple/swift-log to from: "1.6.3" by @renovate[bot] 98 | - Update dependency tuist to v4.44.3 by @renovate[bot] 99 | 100 | ## [0.7.7] - 2025-02-10 101 | ### Details 102 | #### Chore 103 | - Update dependency apple/swift-nio to from: "2.81.0" by @renovate[bot] in [#107](https://github.com/tuist/FileSystem/pull/107) 104 | 105 | ## [0.7.6] - 2025-02-05 106 | ### Details 107 | #### Fix 108 | - Temporary directory to return direct path instead of symlink by @fortmarek in [#106](https://github.com/tuist/FileSystem/pull/106) 109 | 110 | ## [0.7.5] - 2025-02-04 111 | ### Details 112 | #### Chore 113 | - Update dependency apple/swift-nio to from: "2.80.0" by @renovate[bot] in [#104](https://github.com/tuist/FileSystem/pull/104) 114 | 115 | ## [0.7.4] - 2025-02-04 116 | ### Details 117 | #### Fix 118 | - Unstable ZIPFoundation version by @fortmarek in [#105](https://github.com/tuist/FileSystem/pull/105) 119 | 120 | ## [0.7.3] - 2025-02-04 121 | ### Details 122 | #### Fix 123 | - Update URLs APIs to work with linux by @ajkolean in [#103](https://github.com/tuist/FileSystem/pull/103) 124 | 125 | ## New Contributors 126 | * @ajkolean made their first contribution in [#103](https://github.com/tuist/FileSystem/pull/103) 127 | ## [0.7.2] - 2025-01-15 128 | ### Details 129 | #### Chore 130 | - Update dependency apple/swift-nio to from: "2.79.0" by @renovate[bot] in [#102](https://github.com/tuist/FileSystem/pull/102) 131 | 132 | ## [0.7.1] - 2025-01-13 133 | ### Details 134 | #### Chore 135 | - Update dependency apple/swift-nio to from: "2.78.0" by @renovate[bot] in [#101](https://github.com/tuist/FileSystem/pull/101) 136 | 137 | ## [0.7.0] - 2025-01-09 138 | ### Details 139 | #### Feat 140 | - Handle relative symbolic links by @KaiOelfke in [#98](https://github.com/tuist/FileSystem/pull/98) 141 | 142 | ## New Contributors 143 | * @KaiOelfke made their first contribution in [#98](https://github.com/tuist/FileSystem/pull/98) 144 | ## [0.6.24] - 2025-01-09 145 | ### Details 146 | #### Chore 147 | - Update Tuist setup to the latest conventions by @fortmarek in [#100](https://github.com/tuist/FileSystem/pull/100) 148 | 149 | ## [0.6.23] - 2024-12-12 150 | ### Details 151 | #### Chore 152 | - Update dependency tuist to v4.37.0 by @renovate[bot] in [#96](https://github.com/tuist/FileSystem/pull/96) 153 | 154 | ## [0.6.22] - 2024-11-28 155 | ### Details 156 | #### Chore 157 | - Update dependency apple/swift-nio to from: "2.77.0" by @renovate[bot] in [#94](https://github.com/tuist/FileSystem/pull/94) 158 | 159 | ## [0.6.21] - 2024-11-27 160 | ### Details 161 | #### Chore 162 | - Update dependency tuist to v4.36.0 by @renovate[bot] in [#95](https://github.com/tuist/FileSystem/pull/95) 163 | 164 | ## [0.6.20] - 2024-11-27 165 | ### Details 166 | #### Chore 167 | - Update dependency tuist to v4.35.0 by @renovate[bot] in [#91](https://github.com/tuist/FileSystem/pull/91) 168 | 169 | ## [0.6.19] - 2024-11-26 170 | ### Details 171 | #### Chore 172 | - Update dependency apple/swift-log to from: "1.6.2" by @renovate[bot] in [#93](https://github.com/tuist/FileSystem/pull/93) 173 | 174 | ## [0.6.18] - 2024-11-15 175 | ### Details 176 | #### Fix 177 | - Recreating directory when on concurrent move by @fortmarek in [#92](https://github.com/tuist/FileSystem/pull/92) 178 | 179 | ## [0.6.17] - 2024-11-14 180 | ### Details 181 | #### Fix 182 | - Do not search recursively when a glob is a file name wildcard by @fortmarek in [#90](https://github.com/tuist/FileSystem/pull/90) 183 | 184 | ## [0.6.16] - 2024-11-13 185 | ### Details 186 | #### Chore 187 | - Update dependency tuist to v4.34.0 by @renovate[bot] in [#89](https://github.com/tuist/FileSystem/pull/89) 188 | 189 | ## [0.6.15] - 2024-11-11 190 | ### Details 191 | #### Fix 192 | - Glob with extension options by @fortmarek in [#88](https://github.com/tuist/FileSystem/pull/88) 193 | 194 | ## [0.6.14] - 2024-11-07 195 | ### Details 196 | #### Chore 197 | - Update dependency tuist to v4.33.0 by @renovate[bot] in [#87](https://github.com/tuist/FileSystem/pull/87) 198 | 199 | ## [0.6.13] - 2024-11-07 200 | ### Details 201 | #### Fix 202 | - Removing non-existing file or directory by @fortmarek in [#86](https://github.com/tuist/FileSystem/pull/86) 203 | 204 | ## [0.6.12] - 2024-11-07 205 | ### Details 206 | #### Fix 207 | - Use FileManager for file and directory removal to fix performance issues by @fortmarek in [#85](https://github.com/tuist/FileSystem/pull/85) 208 | 209 | ## [0.6.11] - 2024-11-05 210 | ### Details 211 | #### Fix 212 | - Ignore .DS_Store results when globbing by @fortmarek in [#84](https://github.com/tuist/FileSystem/pull/84) 213 | 214 | ## [0.6.10] - 2024-11-05 215 | ### Details 216 | #### Fix 217 | - Glob when base directory is a symlink by @fortmarek in [#83](https://github.com/tuist/FileSystem/pull/83) 218 | 219 | ## [0.6.9] - 2024-11-04 220 | ### Details 221 | #### Refactor 222 | - Migrate used subset of swift-glob to FileSystem by @fortmarek in [#82](https://github.com/tuist/FileSystem/pull/82) 223 | 224 | ## [0.6.8] - 2024-11-04 225 | ### Details 226 | #### Fix 227 | - Path wildcard with constant component by @fortmarek in [#81](https://github.com/tuist/FileSystem/pull/81) 228 | 229 | ## [0.6.7] - 2024-11-04 230 | ### Details 231 | #### Fix 232 | - Matching files with double globstar by @fortmarek in [#80](https://github.com/tuist/FileSystem/pull/80) 233 | 234 | ## [0.6.6] - 2024-11-01 235 | ### Details 236 | #### Chore 237 | - Update dependency tuist to v4.32.1 by @renovate[bot] in [#79](https://github.com/tuist/FileSystem/pull/79) 238 | 239 | ## [0.6.5] - 2024-11-01 240 | ### Details 241 | #### Fix 242 | - Searching for files in symlinked directories by @fortmarek in [#78](https://github.com/tuist/FileSystem/pull/78) 243 | 244 | ## [0.6.4] - 2024-11-01 245 | ### Details 246 | #### Fix 247 | - Include hidden files by default by @fortmarek in [#77](https://github.com/tuist/FileSystem/pull/77) 248 | 249 | ## [0.6.3] - 2024-10-31 250 | ### Details 251 | #### Chore 252 | - Update dependency tuist to v4.32.0 by @renovate[bot] in [#76](https://github.com/tuist/FileSystem/pull/76) 253 | 254 | ## [0.6.2] - 2024-10-30 255 | ### Details 256 | #### Fix 257 | - Searching for a constant file by @fortmarek in [#74](https://github.com/tuist/FileSystem/pull/74) 258 | 259 | ## [0.6.1] - 2024-10-30 260 | ### Details 261 | #### Fix 262 | - Matching files with a trailing path wildcard by @fortmarek in [#72](https://github.com/tuist/FileSystem/pull/72) 263 | 264 | ## [0.6.0] - 2024-10-30 265 | ### Details 266 | #### Feat 267 | - Improve globbing performance by using parallelized Swift glob implementation by @fortmarek in [#68](https://github.com/tuist/FileSystem/pull/68) 268 | 269 | ## [0.5.4] - 2024-10-30 270 | ### Details 271 | #### Chore 272 | - Update dependency tuist/swift-glob to from: "0.3.4" by @renovate[bot] in [#71](https://github.com/tuist/FileSystem/pull/71) 273 | 274 | ## [0.5.3] - 2024-10-29 275 | ### Details 276 | #### Chore 277 | - Update dependency tuist/swift-glob to from: "0.3.0" by @renovate[bot] in [#69](https://github.com/tuist/FileSystem/pull/69) 278 | 279 | ## [0.5.2] - 2024-10-28 280 | ### Details 281 | #### Chore 282 | - Update actions/checkout action to v4 by @renovate[bot] in [#67](https://github.com/tuist/FileSystem/pull/67) 283 | 284 | ## [0.5.1] - 2024-10-28 285 | ### Details 286 | #### Chore 287 | - Add conventional PR check by @fortmarek in [#64](https://github.com/tuist/FileSystem/pull/64) 288 | 289 | ## [0.5.0] - 2024-10-24 290 | ### Details 291 | #### Feat 292 | - Use different implementation of globbing for more stable behavior by @fortmarek in [#66](https://github.com/tuist/FileSystem/pull/66) 293 | 294 | ## [0.4.9] - 2024-10-24 295 | ### Details 296 | #### Chore 297 | - Update dependency apple/swift-nio to from: "2.76.1" by @renovate[bot] in [#65](https://github.com/tuist/FileSystem/pull/65) 298 | 299 | ## [0.4.8] - 2024-10-24 300 | ### Details 301 | #### Fix 302 | - Glob returning paths with resolved symlinks by @fortmarek in [#63](https://github.com/tuist/FileSystem/pull/63) 303 | 304 | ## [0.4.7] - 2024-10-24 305 | ### Details 306 | #### Chore 307 | - Update dependency apple/swift-nio to from: "2.76.0" by @renovate[bot] in [#60](https://github.com/tuist/FileSystem/pull/60) 308 | 309 | ## [0.4.6] - 2024-10-23 310 | ### Details 311 | #### Chore 312 | - Update actions/checkout digest to 11bd719 by @renovate[bot] in [#59](https://github.com/tuist/FileSystem/pull/59) 313 | 314 | ## [0.4.5] - 2024-10-23 315 | ### Details 316 | #### Chore 317 | - Update dependency tuist to v4.31.0 by @renovate[bot] in [#58](https://github.com/tuist/FileSystem/pull/58) 318 | 319 | ## [0.4.4] - 2024-10-16 320 | ### Details 321 | #### Chore 322 | - Update dependency tuist to v4.30.0 by @renovate[bot] in [#57](https://github.com/tuist/FileSystem/pull/57) 323 | 324 | ## [0.4.3] - 2024-10-14 325 | ### Details 326 | #### Chore 327 | - Update dependency apple/swift-nio to from: "2.75.0" by @renovate[bot] in [#56](https://github.com/tuist/FileSystem/pull/56) 328 | 329 | ## [0.4.2] - 2024-10-10 330 | ### Details 331 | #### Chore 332 | - Update dependency tuist to v4.29.1 by @renovate[bot] in [#55](https://github.com/tuist/FileSystem/pull/55) 333 | 334 | ## [0.4.1] - 2024-10-08 335 | ### Details 336 | #### Chore 337 | - Update dependency tuist to v4.29.0 by @renovate[bot] in [#53](https://github.com/tuist/FileSystem/pull/53) 338 | 339 | ## [0.4.0] - 2024-10-08 340 | ### Details 341 | #### Feat 342 | - Add support for getting the current working directory by @fortmarek in [#52](https://github.com/tuist/FileSystem/pull/52) 343 | 344 | ## [0.3.2] - 2024-10-07 345 | ### Details 346 | #### Chore 347 | - Update actions/checkout digest to eef6144 by @renovate[bot] in [#51](https://github.com/tuist/FileSystem/pull/51) 348 | 349 | ## [0.3.1] - 2024-10-07 350 | ### Details 351 | #### Chore 352 | - Update git-cliff by @fortmarek 353 | - Add changelog.md file by @fortmarek in [#49](https://github.com/tuist/FileSystem/pull/49) 354 | - Update dependency tuist to v4.28.2 by @renovate[bot] in [#46](https://github.com/tuist/FileSystem/pull/46) 355 | - Update dependency apple/swift-nio to from: "2.74.0" by @renovate[bot] in [#45](https://github.com/tuist/FileSystem/pull/45) 356 | - Update dependency tuist to v4.28.1 by @renovate[bot] 357 | - Update dependency apple/swift-nio to from: "2.73.0" by @renovate[bot] 358 | 359 | #### Ci 360 | - Use TUIST_FILE_SYSTEM_RELEASE_TOKEN for auto-commit by @fortmarek in [#50](https://github.com/tuist/FileSystem/pull/50) 361 | - Update release workflow to use git cliff by @fortmarek in [#48](https://github.com/tuist/FileSystem/pull/48) 362 | 363 | #### Fix 364 | - Do not throw error when resolving symlink of a plain directory by @fortmarek in [#47](https://github.com/tuist/FileSystem/pull/47) 365 | 366 | [0.10.0]: https://github.com/tuist/FileSystem/compare/0.9.2..0.10.0 367 | [0.9.2]: https://github.com/tuist/FileSystem/compare/0.9.1..0.9.2 368 | [0.9.1]: https://github.com/tuist/FileSystem/compare/0.9.0..0.9.1 369 | [0.9.0]: https://github.com/tuist/FileSystem/compare/0.8.0..0.9.0 370 | [0.8.0]: https://github.com/tuist/FileSystem/compare/0.7.18..0.8.0 371 | [0.7.18]: https://github.com/tuist/FileSystem/compare/0.7.17..0.7.18 372 | [0.7.17]: https://github.com/tuist/FileSystem/compare/0.7.16..0.7.17 373 | [0.7.16]: https://github.com/tuist/FileSystem/compare/0.7.15..0.7.16 374 | [0.7.15]: https://github.com/tuist/FileSystem/compare/0.7.14..0.7.15 375 | [0.7.14]: https://github.com/tuist/FileSystem/compare/0.7.13..0.7.14 376 | [0.7.13]: https://github.com/tuist/FileSystem/compare/0.7.12..0.7.13 377 | [0.7.12]: https://github.com/tuist/FileSystem/compare/0.7.11..0.7.12 378 | [0.7.11]: https://github.com/tuist/FileSystem/compare/0.7.10..0.7.11 379 | [0.7.10]: https://github.com/tuist/FileSystem/compare/0.7.9..0.7.10 380 | [0.7.9]: https://github.com/tuist/FileSystem/compare/0.7.8..0.7.9 381 | [0.7.8]: https://github.com/tuist/FileSystem/compare/0.7.7..0.7.8 382 | [0.7.7]: https://github.com/tuist/FileSystem/compare/0.7.6..0.7.7 383 | [0.7.6]: https://github.com/tuist/FileSystem/compare/0.7.5..0.7.6 384 | [0.7.5]: https://github.com/tuist/FileSystem/compare/0.7.4..0.7.5 385 | [0.7.4]: https://github.com/tuist/FileSystem/compare/0.7.3..0.7.4 386 | [0.7.3]: https://github.com/tuist/FileSystem/compare/0.7.2..0.7.3 387 | [0.7.2]: https://github.com/tuist/FileSystem/compare/0.7.1..0.7.2 388 | [0.7.1]: https://github.com/tuist/FileSystem/compare/0.7.0..0.7.1 389 | [0.7.0]: https://github.com/tuist/FileSystem/compare/0.6.24..0.7.0 390 | [0.6.24]: https://github.com/tuist/FileSystem/compare/0.6.23..0.6.24 391 | [0.6.23]: https://github.com/tuist/FileSystem/compare/0.6.22..0.6.23 392 | [0.6.22]: https://github.com/tuist/FileSystem/compare/0.6.21..0.6.22 393 | [0.6.21]: https://github.com/tuist/FileSystem/compare/0.6.20..0.6.21 394 | [0.6.20]: https://github.com/tuist/FileSystem/compare/0.6.19..0.6.20 395 | [0.6.19]: https://github.com/tuist/FileSystem/compare/0.6.18..0.6.19 396 | [0.6.18]: https://github.com/tuist/FileSystem/compare/0.6.17..0.6.18 397 | [0.6.17]: https://github.com/tuist/FileSystem/compare/0.6.16..0.6.17 398 | [0.6.16]: https://github.com/tuist/FileSystem/compare/0.6.15..0.6.16 399 | [0.6.15]: https://github.com/tuist/FileSystem/compare/0.6.14..0.6.15 400 | [0.6.14]: https://github.com/tuist/FileSystem/compare/0.6.13..0.6.14 401 | [0.6.13]: https://github.com/tuist/FileSystem/compare/0.6.12..0.6.13 402 | [0.6.12]: https://github.com/tuist/FileSystem/compare/0.6.11..0.6.12 403 | [0.6.11]: https://github.com/tuist/FileSystem/compare/0.6.10..0.6.11 404 | [0.6.10]: https://github.com/tuist/FileSystem/compare/0.6.9..0.6.10 405 | [0.6.9]: https://github.com/tuist/FileSystem/compare/0.6.8..0.6.9 406 | [0.6.8]: https://github.com/tuist/FileSystem/compare/0.6.7..0.6.8 407 | [0.6.7]: https://github.com/tuist/FileSystem/compare/0.6.6..0.6.7 408 | [0.6.6]: https://github.com/tuist/FileSystem/compare/0.6.5..0.6.6 409 | [0.6.5]: https://github.com/tuist/FileSystem/compare/0.6.4..0.6.5 410 | [0.6.4]: https://github.com/tuist/FileSystem/compare/0.6.3..0.6.4 411 | [0.6.3]: https://github.com/tuist/FileSystem/compare/0.6.2..0.6.3 412 | [0.6.2]: https://github.com/tuist/FileSystem/compare/0.6.1..0.6.2 413 | [0.6.1]: https://github.com/tuist/FileSystem/compare/0.6.0..0.6.1 414 | [0.6.0]: https://github.com/tuist/FileSystem/compare/0.5.4..0.6.0 415 | [0.5.4]: https://github.com/tuist/FileSystem/compare/0.5.3..0.5.4 416 | [0.5.3]: https://github.com/tuist/FileSystem/compare/0.5.2..0.5.3 417 | [0.5.2]: https://github.com/tuist/FileSystem/compare/0.5.1..0.5.2 418 | [0.5.1]: https://github.com/tuist/FileSystem/compare/0.5.0..0.5.1 419 | [0.5.0]: https://github.com/tuist/FileSystem/compare/0.4.9..0.5.0 420 | [0.4.9]: https://github.com/tuist/FileSystem/compare/0.4.8..0.4.9 421 | [0.4.8]: https://github.com/tuist/FileSystem/compare/0.4.7..0.4.8 422 | [0.4.7]: https://github.com/tuist/FileSystem/compare/0.4.6..0.4.7 423 | [0.4.6]: https://github.com/tuist/FileSystem/compare/0.4.5..0.4.6 424 | [0.4.5]: https://github.com/tuist/FileSystem/compare/0.4.4..0.4.5 425 | [0.4.4]: https://github.com/tuist/FileSystem/compare/0.4.3..0.4.4 426 | [0.4.3]: https://github.com/tuist/FileSystem/compare/0.4.2..0.4.3 427 | [0.4.2]: https://github.com/tuist/FileSystem/compare/0.4.1..0.4.2 428 | [0.4.1]: https://github.com/tuist/FileSystem/compare/0.4.0..0.4.1 429 | [0.4.0]: https://github.com/tuist/FileSystem/compare/0.3.2..0.4.0 430 | [0.3.2]: https://github.com/tuist/FileSystem/compare/0.3.1..0.3.2 431 | [0.3.1]: https://github.com/tuist/FileSystem/compare/0.3.0..0.3.1 432 | 433 | 434 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | contact@tuist.io. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Tuist 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "path", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/tuist/Path", 7 | "state" : { 8 | "revision" : "7c74ac435e03a927c3a73134c48b61e60221abcb", 9 | "version" : "0.3.8" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-atomics", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/apple/swift-atomics.git", 16 | "state" : { 17 | "revision" : "cd142fd2f64be2100422d658e7411e39489da985", 18 | "version" : "1.2.0" 19 | } 20 | }, 21 | { 22 | "identity" : "swift-collections", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/apple/swift-collections.git", 25 | "state" : { 26 | "revision" : "94cf62b3ba8d4bed62680a282d4c25f9c63c2efb", 27 | "version" : "1.1.0" 28 | } 29 | }, 30 | { 31 | "identity" : "swift-log", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/apple/swift-log", 34 | "state" : { 35 | "revision" : "3d8596ed08bd13520157f0355e35caed215ffbfa", 36 | "version" : "1.6.3" 37 | } 38 | }, 39 | { 40 | "identity" : "swift-nio", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/apple/swift-nio", 43 | "state" : { 44 | "revision" : "34d486b01cd891297ac615e40d5999536a1e138d", 45 | "version" : "2.83.0" 46 | } 47 | }, 48 | { 49 | "identity" : "swift-system", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/apple/swift-system.git", 52 | "state" : { 53 | "revision" : "c8a44d836fe7913603e246acab7c528c2e780168", 54 | "version" : "1.4.0" 55 | } 56 | }, 57 | { 58 | "identity" : "zipfoundation", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/tuist/ZIPFoundation", 61 | "state" : { 62 | "revision" : "e9b1917bd4d7d050e0ff4ec157b5d6e253c84385", 63 | "version" : "0.9.20" 64 | } 65 | } 66 | ], 67 | "version" : 2 68 | } 69 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.8.1 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | @preconcurrency import PackageDescription 5 | 6 | let package = Package( 7 | name: "FileSystem", 8 | platforms: [ 9 | .macOS("13.0"), 10 | .iOS("16.0"), 11 | ], 12 | products: [ 13 | .library( 14 | name: "FileSystem", 15 | type: .static, 16 | targets: ["FileSystem"] 17 | ), 18 | .library( 19 | name: "FileSystemTesting", 20 | type: .static, 21 | targets: ["FileSystemTesting"] 22 | ), 23 | .library( 24 | name: "Glob", 25 | type: .static, 26 | targets: ["Glob"] 27 | ), 28 | ], 29 | dependencies: [ 30 | .package(url: "https://github.com/tuist/Path", .upToNextMajor(from: "0.3.8")), 31 | .package(url: "https://github.com/apple/swift-nio", .upToNextMajor(from: "2.83.0")), 32 | .package(url: "https://github.com/apple/swift-log", .upToNextMajor(from: "1.6.3")), 33 | .package(url: "https://github.com/tuist/ZIPFoundation", .upToNextMajor(from: "0.9.20")), 34 | ], 35 | targets: [ 36 | .target( 37 | name: "FileSystem", 38 | dependencies: [ 39 | "Glob", 40 | .product(name: "_NIOFileSystem", package: "swift-nio"), 41 | .product(name: "Path", package: "Path"), 42 | .product(name: "Logging", package: "swift-log"), 43 | .product(name: "ZIPFoundation", package: "ZIPFoundation"), 44 | ], 45 | swiftSettings: [ 46 | .define("MOCKING", .when(configuration: .debug)), 47 | ] 48 | ), 49 | .testTarget( 50 | name: "FileSystemTests", 51 | dependencies: [ 52 | "FileSystem", 53 | ] 54 | ), 55 | .target( 56 | name: "FileSystemTesting", 57 | dependencies: [ 58 | "FileSystem", 59 | ] 60 | ), 61 | .testTarget( 62 | name: "FileSystemTestingTests", 63 | dependencies: [ 64 | "FileSystem", 65 | "FileSystemTesting", 66 | ] 67 | ), 68 | .target( 69 | name: "Glob", 70 | swiftSettings: [ 71 | .enableExperimentalFeature("StrictConcurrency"), 72 | ] 73 | ), 74 | .testTarget( 75 | name: "GlobTests", 76 | dependencies: [ 77 | "Glob", 78 | ] 79 | ), 80 | ] 81 | ) 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FileSystem 2 | 3 | FileSystem is a Swift Package that provides a simple cross-platform API to interact with the file system. 4 | 5 | ## Motivation 6 | Why build a Swift Package for interacting with the file system if there's already `FileManager`? Here are the motivations: 7 | 8 | - Providing human-friendly errors that are ok to present to the user. 9 | - Integrating with [swift-log](https://github.com/apple/swift-log) to give consumers the ability to log file system operations. 10 | - Embracing Swift's structured concurrency with async/await. 11 | - Providing an API where paths are always absolute, makes it easier to reason about the file system operations. 12 | 13 | > [!NOTE] 14 | > FileSystem powers [Tuist](https://tuist.io), a toolchain to build better apps faster. 15 | 16 | ## Add it to your project 17 | 18 | ### Swift Package Manager 19 | 20 | You can edit your project's `Package.swift` and add `FileSystem` as a dependency: 21 | 22 | ```swift 23 | import PackageDescription 24 | 25 | let package = Package( 26 | name: "MyProject", 27 | dependencies: [ 28 | .package(url: "https://github.com/tuist/FileSystem.git", .upToNextMajor(from: "0.1.0")) 29 | ], 30 | targets: [ 31 | .target(name: "MyProject", 32 | dependencies: ["FileSystem", .product(name: "FileSystem", package: "FileSystem")]), 33 | ] 34 | ) 35 | ``` 36 | 37 | ### Tuist 38 | 39 | First, you'll have to add the `FileSystem` package to your project's `Package.swift` file: 40 | 41 | ```swift 42 | import PackageDescription 43 | 44 | let package = Package( 45 | name: "MyProject", 46 | dependencies: [ 47 | .package(url: "https://github.com/tuist/FileSystem.git", .upToNextMajor(from: "0.1.0")) 48 | ] 49 | ) 50 | ``` 51 | 52 | And then declare it as a dependency of one of your project's targets: 53 | 54 | ```swift 55 | // Project.swift 56 | import ProjectDescription 57 | 58 | let project = Project( 59 | name: "App", 60 | organizationName: "tuist.io", 61 | targets: [ 62 | .target( 63 | name: "App", 64 | destinations: [.iPhone], 65 | product: .app, 66 | bundleId: "io.tuist.app", 67 | deploymentTargets: .iOS("13.0"), 68 | infoPlist: .default, 69 | sources: ["Targets/App/Sources/**"], 70 | dependencies: [ 71 | .external(name: "FileSystem"), 72 | ] 73 | ), 74 | ] 75 | ) 76 | ``` 77 | 78 | ## Development 79 | 80 | ### Using Tuist 81 | 82 | 1. Clone the repository: `git clone https://github.com/tuist/FileSystem.git` 83 | 2. Generate the project: `tuist generate` 84 | 85 | 86 | ### Using Swift Package Manager 87 | 88 | 1. Clone the repository: `git clone https://github.com/tuist/FileSystem.git` 89 | 2. Open the `Package.swift` with Xcode -------------------------------------------------------------------------------- /Sources/FileSystem/AsyncSequence+Extras.swift: -------------------------------------------------------------------------------- 1 | /// A throwing async sequence that performs type erasure by wrapping another throwing async sequence. 2 | /// 3 | /// If the async sequence that you wish to type erase doesn't throw, then use `AnyAsyncSequenceable`. 4 | public struct AnyThrowingAsyncSequenceable: AsyncSequence, Sendable { 5 | private var _makeAsyncIterator: @Sendable () -> Iterator 6 | 7 | // MARK: Initialization 8 | 9 | /// Creates a type erasing async sequence. 10 | /// - Parameters: 11 | /// - sequence: The async sequence to type erase. 12 | public init(_ sequence: T) where T: AsyncSequence, T: Sendable, T.Element == Element { 13 | _makeAsyncIterator = { Iterator(sequence.makeAsyncIterator()) } 14 | } 15 | 16 | /// Creates an optional type erasing async sequence. 17 | /// - Parameters: 18 | /// - sequence: An optional async sequence to type erase. 19 | public init?(_ asyncSequence: T?) where T: AsyncSequence, T: Sendable, T.Element == Element { 20 | guard let asyncSequence else { return nil } 21 | self = .init(asyncSequence) 22 | } 23 | 24 | // MARK: AsyncSequence 25 | 26 | public func makeAsyncIterator() -> Iterator { 27 | _makeAsyncIterator() 28 | } 29 | } 30 | 31 | // MARK: Iterator 32 | 33 | extension AnyThrowingAsyncSequenceable { 34 | public struct Iterator: AsyncIteratorProtocol { 35 | private var iterator: any AsyncIteratorProtocol 36 | 37 | // MARK: Initialization 38 | 39 | init(_ iterator: T) where T: AsyncIteratorProtocol, T.Element == Element { 40 | self.iterator = iterator 41 | } 42 | 43 | // MARK: AsyncIteratorProtocol 44 | 45 | public mutating func next() async throws -> Element? { 46 | // NOTE: When `AsyncSequence`, `AsyncIteratorProtocol` get their Element as 47 | // their primary associated type we won't need the casting. 48 | // https://github.com/apple/swift-evolution/blob/main/proposals/0358-primary-associated-types-in-stdlib.md#alternatives-considered 49 | try await iterator.next() as? Element 50 | } 51 | } 52 | } 53 | 54 | // MARK: Erasure 55 | 56 | extension AsyncSequence { 57 | /// Creates a throwing type erasing async sequence. 58 | /// 59 | /// If the async sequence that you wish to type erase deson't throw, 60 | /// then use `eraseToAnyAsyncSequenceable()`. 61 | /// - Returns: A typed erased async sequence. 62 | public func eraseToAnyThrowingAsyncSequenceable() -> AnyThrowingAsyncSequenceable where Self: Sendable { 63 | .init(self) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/FileSystem/FileSystem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Glob 3 | import Logging 4 | import NIOCore 5 | import NIOFileSystem 6 | import Path 7 | import ZIPFoundation 8 | 9 | public enum FileSystemItemType: CaseIterable, Equatable { 10 | case directory 11 | case file 12 | } 13 | 14 | public enum FileSystemError: Equatable, Error, CustomStringConvertible { 15 | case moveNotFound(from: AbsolutePath, to: AbsolutePath) 16 | case makeDirectoryAbsentParent(AbsolutePath) 17 | case readInvalidEncoding(String.Encoding, path: AbsolutePath) 18 | case cantEncodeText(String, String.Encoding) 19 | case replacingItemAbsent(replacingPath: AbsolutePath, replacedPath: AbsolutePath) 20 | case copiedItemAbsent(copiedPath: AbsolutePath, intoPath: AbsolutePath) 21 | case absentSymbolicLink(AbsolutePath) 22 | 23 | public var description: String { 24 | switch self { 25 | case let .moveNotFound(from, to): 26 | return "The file or directory at path \(from.pathString) couldn't be moved to \(to.parentDirectory.pathString). Ensure the source file or directory and the target's parent directory exist." 27 | case let .makeDirectoryAbsentParent(path): 28 | return "Couldn't create the directory at path \(path.pathString) because its parent directory doesn't exists." 29 | case let .readInvalidEncoding(encoding, path): 30 | return "Couldn't text-decode the content of the file at path \(path.pathString) using the encoding \(encoding.description). Ensure that the encoding is the right one." 31 | case let .cantEncodeText(text, encoding): 32 | return "Couldn't encode the following text using the encoding \(encoding):\n\(text)" 33 | case let .replacingItemAbsent(replacingPath, replacedPath): 34 | return "Couldn't replace file or directory at \(replacedPath.pathString) with item at \(replacingPath.pathString) because the latter doesn't exist." 35 | case let .copiedItemAbsent(copiedPath, intoPath): 36 | return "Couldn't copy the file or directory at \(copiedPath.pathString) to \(intoPath.pathString) because the former doesn't exist." 37 | case let .absentSymbolicLink(path): 38 | return "Couldn't resolve the symbolic link at path \(path.pathString) because it doesn't exist." 39 | } 40 | } 41 | } 42 | 43 | /// Options to configure the move operation. 44 | public enum MoveOptions: String { 45 | /// When passed, it creates the parent directories of the target path if needed. 46 | case createTargetParentDirectories 47 | } 48 | 49 | /// Options to configure the make directory operation. 50 | public enum MakeDirectoryOptions: String { 51 | /// When passed, it creates the parent directories if needed. 52 | case createTargetParentDirectories 53 | } 54 | 55 | /// Options to configure the writing of text files. 56 | public enum WriteTextOptions { 57 | /// When passed, it ovewrites any existing files. 58 | case overwrite 59 | } 60 | 61 | /// Options to configure the writing of Plist files. 62 | public enum WritePlistOptions { 63 | /// When passed, it ovewrites any existing files. 64 | case overwrite 65 | } 66 | 67 | /// Options to configure the writing of JSON files. 68 | public enum WriteJSONOptions { 69 | /// When passed, it ovewrites any existing files. 70 | case overwrite 71 | } 72 | 73 | public protocol FileSysteming { 74 | func runInTemporaryDirectory( 75 | prefix: String, 76 | _ action: @Sendable (_ temporaryDirectory: AbsolutePath) async throws -> T 77 | ) async throws -> T 78 | 79 | /// It checks for the presence of a file or directory. 80 | /// - Parameter path: The path to be checked. 81 | /// - Returns: Returns true if a file or directory exists. 82 | func exists(_ path: AbsolutePath) async throws -> Bool 83 | 84 | /// It checks for the presence of files and directories. 85 | /// - Parameters: 86 | /// - path: The path to be checked. 87 | /// - isDirectory: True if it should be checked that the path represents a directory. 88 | /// - Returns: Returns true if a file or directory (depending on the `isDirectory` argument) is present. 89 | func exists(_ path: AbsolutePath, isDirectory: Bool) async throws -> Bool 90 | 91 | /// Creates a file at the given path 92 | /// - Parameter path: Path where an empty file will be created. 93 | func touch(_ path: AbsolutePath) async throws 94 | 95 | /// It removes the file or directory at the given path. 96 | /// - Parameter path: The path to the file or directory to remove. 97 | func remove(_ path: AbsolutePath) async throws 98 | 99 | /// Creates a temporary directory and returns its path. 100 | /// - Parameter prefix: Prefix for the randomly-generated directory name. 101 | /// - Returns: The path to the directory. 102 | func makeTemporaryDirectory(prefix: String) async throws -> AbsolutePath 103 | 104 | /// Moves a file or directory from one path to another. 105 | /// If the parent directory of the target path doesn't exist, it creates it by default. 106 | /// - Parameters: 107 | /// - from: The path to move the file or directory from. 108 | /// - to: The path to move the file or directory to. 109 | func move(from: AbsolutePath, to: AbsolutePath) async throws 110 | 111 | /// Moves a file or directory from one path to another. 112 | /// - Parameters: 113 | /// - from: The path to move the file or directory from. 114 | /// - to: The path to move the file or directory to. 115 | /// - options: Options to configure the moving operation. 116 | func move(from: AbsolutePath, to: AbsolutePath, options: [MoveOptions]) async throws 117 | 118 | /// Makes a directory at the given path. 119 | /// - Parameter at: The path at which the directory will be created 120 | func makeDirectory(at: AbsolutePath) async throws 121 | 122 | /// Makes a directory at the given path. 123 | /// - Parameters: 124 | /// - at: The path at which the directory will be created 125 | /// - options: Options to configure the operation. 126 | func makeDirectory(at: AbsolutePath, options: [MakeDirectoryOptions]) async throws 127 | 128 | /// Reads the file at path and returns it as data. 129 | /// - Parameter at: Path to the file to read. 130 | /// - Returns: The content of the file as data. 131 | func readFile(at: AbsolutePath) async throws -> Data 132 | 133 | /// Reads the file at a given path, decodes its content using UTF-8 encoding, and returns the content as a String. 134 | /// - Parameter at: Path to the file to read. 135 | /// - Returns: The content of the file. 136 | func readTextFile(at: AbsolutePath) async throws -> String 137 | 138 | /// Reads the file at a given path, decodes its content using the provided encoding, and returns the content as a String. 139 | /// - Parameters: 140 | /// - at: Path to the file to read. 141 | /// - encoding: The encoding of the content represented by the data. 142 | /// - Returns: The content of the file. 143 | func readTextFile(at: Path.AbsolutePath, encoding: String.Encoding) async throws -> String 144 | 145 | /// It writes the text at the given path. It encodes the text using UTF-8 146 | /// - Parameters: 147 | /// - text: Text to be written. 148 | /// - at: Path at which the text will be written. 149 | func writeText(_ text: String, at: AbsolutePath) async throws 150 | 151 | /// It writes the text at the given path. 152 | /// - Parameters: 153 | /// - text: Text to be written. 154 | /// - at: Path at which the text will be written. 155 | /// - encoding: The encoding to encode the text as data. 156 | func writeText(_ text: String, at: AbsolutePath, encoding: String.Encoding) async throws 157 | 158 | /// It writes the text at the given path. 159 | /// - Parameters: 160 | /// - text: Text to be written. 161 | /// - at: Path at which the text will be written. 162 | /// - encoding: The encoding to encode the text as data. 163 | /// - options: Options to configure the writing of the file. 164 | func writeText(_ text: String, at: AbsolutePath, encoding: String.Encoding, options: Set) async throws 165 | 166 | /// Reads a property list file at a given path, and decodes it into the provided decodable type. 167 | /// - Parameter at: The path to the property list file. 168 | /// - Returns: The decoded structure. 169 | func readPlistFile(at: AbsolutePath) async throws -> T 170 | 171 | /// Reads a property list file at a given path, and decodes it into the provided decodable type. 172 | /// - Parameters: 173 | /// - at: The path to the property list file. 174 | /// - decoder: The property list decoder to use. 175 | /// - Returns: The decoded instance. 176 | func readPlistFile(at: AbsolutePath, decoder: PropertyListDecoder) async throws -> T 177 | 178 | /// Given an `Encodable` instance, it encodes it as a Plist, and writes it at the given path. 179 | /// - Parameters: 180 | /// - item: Item to be encoded as Plist. 181 | /// - at: Path at which the Plist will be written. 182 | func writeAsPlist(_ item: T, at: AbsolutePath) async throws 183 | 184 | /// Given an `Encodable` instance, it encodes it as a Plist, and writes it at the given path. 185 | /// - Parameters: 186 | /// - item: Item to be encoded as Plist. 187 | /// - at: Path at which the Plist will be written. 188 | /// - encoder: The PropertyListEncoder instance to encode the item. 189 | func writeAsPlist(_ item: T, at: AbsolutePath, encoder: PropertyListEncoder) async throws 190 | 191 | /// Given an `Encodable` instance, it encodes it as a Plist, and writes it at the given path. 192 | /// - Parameters: 193 | /// - item: Item to be encoded as Plist. 194 | /// - at: Path at which the Plist will be written. 195 | /// - encoder: The PropertyListEncoder instance to encode the item. 196 | /// - options: Options to configure the writing of the plist file. 197 | func writeAsPlist( 198 | _ item: T, 199 | at: AbsolutePath, 200 | encoder: PropertyListEncoder, 201 | options: Set 202 | ) async throws 203 | 204 | /// Reads a JSON file at a given path, and decodes it into the provided decodable type. 205 | /// - Parameter at: The path to the property list file. 206 | /// - Returns: The decoded structure. 207 | func readJSONFile(at: AbsolutePath) async throws -> T 208 | 209 | /// Reads a JSON file at a given path, and decodes it into the provided decodable type. 210 | /// - Parameters: 211 | /// - at: The path to the property list file. 212 | /// - decoder: The JSON decoder to use. 213 | /// - Returns: The decoded instance. 214 | func readJSONFile(at: AbsolutePath, decoder: JSONDecoder) async throws -> T 215 | 216 | /// Given an `Encodable` instance, it encodes it as a JSON, and writes it at the given path. 217 | /// - Parameters: 218 | /// - item: Item to be encoded as JSON. 219 | /// - at: Path at which the JSON will be written. 220 | func writeAsJSON(_ item: T, at: AbsolutePath) async throws 221 | 222 | /// Given an `Encodable` instance, it encodes it as a JSON, and writes it at the given path. 223 | /// - Parameters: 224 | /// - item: Item to be encoded as JSON. 225 | /// - at: Path at which the JSON will be written. 226 | /// - encoder: The JSONEncoder instance to encode the item. 227 | func writeAsJSON(_ item: T, at: AbsolutePath, encoder: JSONEncoder) async throws 228 | 229 | /// Given an `Encodable` instance, it encodes it as a JSON, and writes it at the given path. 230 | /// - Parameters: 231 | /// - item: Item to be encoded as JSON. 232 | /// - at: Path at which the JSON will be written. 233 | /// - encoder: The JSONEncoder instance to encode the item. 234 | /// - options: Options to configure the writing of the JSON file. 235 | func writeAsJSON(_ item: T, at: AbsolutePath, encoder: JSONEncoder, options: Set) async throws 236 | 237 | /// Returns the size of a file at a given path. If the file doesn't exist, it returns nil. 238 | /// - Parameter at: Path to the file whose size will be returned. 239 | /// - Returns: The file size, otherwise `nil` 240 | func fileSizeInBytes(at: AbsolutePath) async throws -> Int64? 241 | 242 | /// Given a path, it replaces it with the file or directory at the other path. 243 | /// - Parameters: 244 | /// - to: The path to be replaced. 245 | /// - with: The path to the directory or file to replace the other path with. 246 | func replace(_ to: AbsolutePath, with: AbsolutePath) async throws 247 | 248 | /// Given a path, it copies the file or directory to another path. 249 | /// - Parameters: 250 | /// - from: The path to the file or directory to be copied. 251 | /// - to: The path to copy the file or directory to. 252 | func copy(_ from: AbsolutePath, to: AbsolutePath) async throws 253 | 254 | /// Given a path, it traverses the hierarcy until it finds a file or directory whose absolute path is formed by concatenating 255 | /// the looked up path and the given relative path. The search stops when the file-system root path, `/`, is reached. 256 | /// 257 | /// - Parameters: 258 | /// - from: The path to traverse plan. This one will also be checked against. 259 | /// - relativePath: The relative path to append to every traversed path. 260 | /// 261 | /// - Returns: The found path. Otherwise it returns `nil`. 262 | func locateTraversingUp(from: AbsolutePath, relativePath: RelativePath) async throws -> AbsolutePath? 263 | 264 | /// Creates a symlink. 265 | /// - Parameters: 266 | /// - from: The path where the symlink is created. 267 | /// - to: The path the symlink points to. 268 | func createSymbolicLink(from: AbsolutePath, to: AbsolutePath) async throws 269 | 270 | /// Creates a relative symlink. 271 | /// - Parameters: 272 | /// - from: The path where the symlink is created. 273 | /// - to: The relative path the symlink points to. 274 | func createSymbolicLink(from: AbsolutePath, to: RelativePath) async throws 275 | 276 | /// Given a symlink, it resolves it returning the path to the file or directory the symlink is pointing to. 277 | /// - Parameter symlinkPath: The absolute path to the symlink. 278 | /// - Returns: The resolved path. 279 | func resolveSymbolicLink(_ symlinkPath: AbsolutePath) async throws -> AbsolutePath 280 | 281 | /// Zips a file or the content of a given directory. 282 | /// - Parameters: 283 | /// - path: Path to file or directory. When the path to a file is provided, the file is zipped. When the path points to a 284 | /// directory, the content of the directory is zipped. 285 | /// - to: Path to where the zip file will be created. 286 | func zipFileOrDirectoryContent(at path: AbsolutePath, to: AbsolutePath) async throws 287 | 288 | /// Unzips a zip file. 289 | /// - Parameters: 290 | /// - zipPath: Path to the zip file. 291 | /// - to: Path to the directory into which the content will be unzipped. 292 | func unzip(_ zipPath: AbsolutePath, to: AbsolutePath) async throws 293 | 294 | /// Looks up files and directories that match a set of glob patterns. 295 | /// - Parameters: 296 | /// - directory: Base absolute directory that glob patterns are relative to. 297 | /// - include: A list of glob patterns. 298 | /// - Returns: An async sequence to get the results. 299 | func glob(directory: Path.AbsolutePath, include: [String]) throws -> AnyThrowingAsyncSequenceable 300 | 301 | /// Returns the path of the current working directory. 302 | func currentWorkingDirectory() async throws -> AbsolutePath 303 | 304 | // TODO: 305 | // func urlSafeBase64MD5(path: AbsolutePath) throws -> String 306 | // func fileAttributes(at path: AbsolutePath) throws -> [FileAttributeKey: Any] 307 | // func files(in path: AbsolutePath, nameFilter: Set?, extensionFilter: Set?) -> Set 308 | // func contentsOfDirectory(_ path: AbsolutePath) throws -> [AbsolutePath] 309 | // func filesAndDirectoriesContained(in path: AbsolutePath) throws -> [AbsolutePath]? 310 | } 311 | 312 | // swiftlint:disable:next type_body_length 313 | public struct FileSystem: FileSysteming, Sendable { 314 | fileprivate let logger: Logger? 315 | fileprivate let environmentVariables: [String: String] 316 | 317 | public init(environmentVariables: [String: String] = ProcessInfo.processInfo.environment, logger: Logger? = nil) { 318 | self.environmentVariables = environmentVariables 319 | self.logger = logger 320 | } 321 | 322 | public func currentWorkingDirectory() async throws -> AbsolutePath { 323 | try await NIOFileSystem.FileSystem.shared.currentWorkingDirectory.path 324 | } 325 | 326 | public func exists(_ path: AbsolutePath) async throws -> Bool { 327 | logger?.debug("Checking if a file or directory exists at path \(path.pathString).") 328 | let info = try await NIOFileSystem.FileSystem.shared.info(forFileAt: .init(path.pathString)) 329 | return info != nil 330 | } 331 | 332 | public func exists(_ path: AbsolutePath, isDirectory: Bool) async throws -> Bool { 333 | if isDirectory { 334 | logger?.debug("Checking if a directory exists at path \(path.pathString).") 335 | } else { 336 | logger?.debug("Checking if a file exists at path \(path.pathString).") 337 | } 338 | guard let info = try await NIOFileSystem.FileSystem.shared.info(forFileAt: .init(path.pathString)) else { 339 | return false 340 | } 341 | return info.type == (isDirectory ? .directory : .regular) 342 | } 343 | 344 | public func touch(_ path: Path.AbsolutePath) async throws { 345 | logger?.debug("Touching a file at path \(path.pathString).") 346 | _ = try await NIOFileSystem.FileSystem.shared.withFileHandle(forWritingAt: .init(path.pathString)) { writer in 347 | try await writer.write(contentsOf: "".data(using: .utf8)!, toAbsoluteOffset: 0) 348 | } 349 | } 350 | 351 | public func remove(_ path: AbsolutePath) async throws { 352 | logger?.debug("Removing the file or directory at path: \(path.pathString).") 353 | guard try await exists(path) else { return } 354 | try await Task { 355 | try FileManager.default.removeItem(atPath: path.pathString) 356 | } 357 | .value 358 | } 359 | 360 | public func makeTemporaryDirectory(prefix: String) async throws -> AbsolutePath { 361 | var systemTemporaryDirectory = NSTemporaryDirectory() 362 | 363 | /// The path to the directory /var is a symlink to /var/private. 364 | /// NSTemporaryDirectory() returns the path to the symlink, so the logic here removes the symlink from it. 365 | #if os(macOS) 366 | if systemTemporaryDirectory.starts(with: "/var/") { 367 | systemTemporaryDirectory = "/private\(systemTemporaryDirectory)" 368 | } 369 | #endif 370 | let temporaryDirectory = try AbsolutePath(validating: systemTemporaryDirectory) 371 | .appending(component: "\(prefix)-\(UUID().uuidString)") 372 | logger?.debug("Creating a temporary directory at path \(temporaryDirectory.pathString).") 373 | try FileManager.default.createDirectory( 374 | at: URL(fileURLWithPath: temporaryDirectory.pathString), 375 | withIntermediateDirectories: true 376 | ) 377 | return temporaryDirectory 378 | } 379 | 380 | public func move(from: Path.AbsolutePath, to: Path.AbsolutePath) async throws { 381 | try await move(from: from, to: to, options: [.createTargetParentDirectories]) 382 | } 383 | 384 | public func move(from: AbsolutePath, to: AbsolutePath, options: [MoveOptions]) async throws { 385 | if options.isEmpty { 386 | logger?.debug("Moving the file or directory from path \(from.pathString) to \(to.pathString).") 387 | } else { 388 | logger? 389 | .debug( 390 | "Moving the file or directory from path \(from.pathString) to \(to.pathString) with options: \(options.map(\.rawValue).joined(separator: ", "))." 391 | ) 392 | } 393 | do { 394 | if options.contains(.createTargetParentDirectories) { 395 | if !(try await exists(to.parentDirectory, isDirectory: true)) { 396 | try? await makeDirectory(at: to.parentDirectory, options: [.createTargetParentDirectories]) 397 | } 398 | } 399 | try await NIOFileSystem.FileSystem.shared.moveItem(at: .init(from.pathString), to: .init(to.pathString)) 400 | } catch let error as NIOFileSystem.FileSystemError { 401 | if error.code == .notFound { 402 | throw FileSystemError.moveNotFound(from: from, to: to) 403 | } else { 404 | throw error 405 | } 406 | } 407 | } 408 | 409 | public func makeDirectory(at: Path.AbsolutePath) async throws { 410 | try await makeDirectory(at: at, options: [.createTargetParentDirectories]) 411 | } 412 | 413 | public func makeDirectory(at: Path.AbsolutePath, options: [MakeDirectoryOptions]) async throws { 414 | if options.isEmpty { 415 | logger? 416 | .debug( 417 | "Creating directory at path \(at.pathString) with options: \(options.map(\.rawValue).joined(separator: ", "))." 418 | ) 419 | } else { 420 | logger?.debug("Creating directory at path \(at.pathString).") 421 | } 422 | do { 423 | try await NIOFileSystem.FileSystem.shared.createDirectory( 424 | at: .init(at.pathString), 425 | withIntermediateDirectories: options 426 | .contains(.createTargetParentDirectories) 427 | ) 428 | } catch let error as NIOFileSystem.FileSystemError { 429 | if error.code == .invalidArgument { 430 | throw FileSystemError.makeDirectoryAbsentParent(at) 431 | } else { 432 | throw error 433 | } 434 | } 435 | } 436 | 437 | public func readFile(at path: Path.AbsolutePath) async throws -> Data { 438 | try await readFile(at: path, log: true) 439 | } 440 | 441 | private func readFile(at path: Path.AbsolutePath, log: Bool = false) async throws -> Data { 442 | if log { 443 | logger?.debug("Reading file at path \(path.pathString).") 444 | } 445 | let handle = try await NIOFileSystem.FileSystem.shared.openFile(forReadingAt: .init(path.pathString), options: .init()) 446 | 447 | let result: Result 448 | do { 449 | var bytes: [UInt8] = [] 450 | for try await var chunk in handle.readChunks() { 451 | let chunkBytes = chunk.readBytes(length: chunk.readableBytes) ?? [] 452 | bytes.append(contentsOf: chunkBytes) 453 | } 454 | result = .success(Data(bytes)) 455 | } catch { 456 | result = .failure(error) 457 | } 458 | try await handle.close() 459 | switch result { 460 | case let .success(data): return data 461 | case let .failure(error): throw error 462 | } 463 | } 464 | 465 | public func readTextFile(at: Path.AbsolutePath) async throws -> String { 466 | try await readTextFile(at: at, encoding: .utf8) 467 | } 468 | 469 | public func readTextFile(at path: Path.AbsolutePath, encoding: String.Encoding) async throws -> String { 470 | logger?.debug("Reading text file at path \(path.pathString) using encoding \(encoding.description).") 471 | let data = try await readFile(at: path) 472 | guard let string = String(data: data, encoding: encoding) else { 473 | throw FileSystemError.readInvalidEncoding(encoding, path: path) 474 | } 475 | return string 476 | } 477 | 478 | public func writeText(_ text: String, at path: AbsolutePath) async throws { 479 | try await writeText(text, at: path, encoding: .utf8) 480 | } 481 | 482 | public func writeText(_ text: String, at path: AbsolutePath, encoding: String.Encoding) async throws { 483 | try await writeText(text, at: path, encoding: encoding, options: Set()) 484 | } 485 | 486 | public func writeText( 487 | _ text: String, 488 | at path: AbsolutePath, 489 | encoding: String.Encoding, 490 | options: Set 491 | ) async throws { 492 | logger?.debug("Writing text at path \(path.pathString).") 493 | guard let data = text.data(using: encoding) else { 494 | throw FileSystemError.cantEncodeText(text, encoding) 495 | } 496 | 497 | if options.contains(.overwrite), try await exists(path) { 498 | try await remove(path) 499 | } 500 | 501 | _ = try await NIOFileSystem.FileSystem.shared.withFileHandle(forWritingAt: .init(path.pathString)) { handler in 502 | try await handler.write(contentsOf: data, toAbsoluteOffset: 0) 503 | } 504 | } 505 | 506 | public func readPlistFile(at path: Path.AbsolutePath) async throws -> T where T: Decodable { 507 | try await readPlistFile(at: path, decoder: PropertyListDecoder()) 508 | } 509 | 510 | public func readPlistFile(at path: Path.AbsolutePath, decoder: PropertyListDecoder) async throws -> T where T: Decodable { 511 | logger?.debug("Reading .plist file at path \(path.pathString).") 512 | let data = try await readFile(at: path) 513 | return try decoder.decode(T.self, from: data) 514 | } 515 | 516 | public func writeAsPlist(_ item: some Encodable, at path: AbsolutePath) async throws { 517 | try await writeAsPlist(item, at: path, encoder: PropertyListEncoder()) 518 | } 519 | 520 | public func writeAsPlist(_ item: some Encodable, at path: AbsolutePath, encoder: PropertyListEncoder) async throws { 521 | try await writeAsPlist(item, at: path, encoder: encoder, options: Set()) 522 | } 523 | 524 | public func writeAsPlist( 525 | _ item: some Encodable, 526 | at path: AbsolutePath, 527 | encoder: PropertyListEncoder, 528 | options: Set 529 | ) async throws { 530 | logger?.debug("Writing .plist at path \(path.pathString).") 531 | 532 | if options.contains(.overwrite), try await exists(path) { 533 | try await remove(path) 534 | } 535 | 536 | let json = try encoder.encode(item) 537 | _ = try await NIOFileSystem.FileSystem.shared.withFileHandle(forWritingAt: .init(path.pathString)) { handler in 538 | try await handler.write(contentsOf: json, toAbsoluteOffset: 0) 539 | } 540 | } 541 | 542 | public func readJSONFile(at path: Path.AbsolutePath) async throws -> T where T: Decodable { 543 | try await readJSONFile(at: path, decoder: JSONDecoder()) 544 | } 545 | 546 | public func readJSONFile(at path: Path.AbsolutePath, decoder: JSONDecoder) async throws -> T where T: Decodable { 547 | logger?.debug("Reading .json file at path \(path.pathString).") 548 | let data = try await readFile(at: path) 549 | return try decoder.decode(T.self, from: data) 550 | } 551 | 552 | public func writeAsJSON(_ item: some Encodable, at path: AbsolutePath) async throws { 553 | try await writeAsJSON(item, at: path, encoder: JSONEncoder()) 554 | } 555 | 556 | public func writeAsJSON(_ item: some Encodable, at path: AbsolutePath, encoder: JSONEncoder) async throws { 557 | try await writeAsJSON(item, at: path, encoder: encoder, options: Set()) 558 | } 559 | 560 | public func writeAsJSON( 561 | _ item: some Encodable, 562 | at path: Path.AbsolutePath, 563 | encoder: JSONEncoder, 564 | options: Set 565 | ) async throws { 566 | logger?.debug("Writing .json at path \(path.pathString).") 567 | 568 | let json = try encoder.encode(item) 569 | if options.contains(.overwrite), try await exists(path) { 570 | try await remove(path) 571 | } 572 | 573 | _ = try await NIOFileSystem.FileSystem.shared.withFileHandle(forWritingAt: .init(path.pathString)) { handler in 574 | try await handler.write(contentsOf: json, toAbsoluteOffset: 0) 575 | } 576 | } 577 | 578 | public func replace(_ to: AbsolutePath, with path: AbsolutePath) async throws { 579 | logger?.debug("Replacing file or directory at path \(path.pathString) with item at path \(to.pathString).") 580 | if !(try await exists(path)) { 581 | throw FileSystemError.replacingItemAbsent(replacingPath: path, replacedPath: to) 582 | } 583 | if !(try await exists(to.parentDirectory)) { 584 | try await makeDirectory(at: to.parentDirectory) 585 | } 586 | try await NIOFileSystem.FileSystem.shared.replaceItem(at: .init(to.pathString), withItemAt: .init(path.pathString)) 587 | } 588 | 589 | public func copy(_ from: AbsolutePath, to: AbsolutePath) async throws { 590 | logger?.debug("Copying file or directory at path \(from.pathString) to \(to.pathString).") 591 | if !(try await exists(from)) { 592 | throw FileSystemError.copiedItemAbsent(copiedPath: from, intoPath: to) 593 | } 594 | if !(try await exists(to.parentDirectory)) { 595 | try await makeDirectory(at: to.parentDirectory) 596 | } 597 | try await NIOFileSystem.FileSystem.shared.copyItem(at: .init(from.pathString), to: .init(to.pathString)) 598 | } 599 | 600 | public func runInTemporaryDirectory( 601 | prefix: String, 602 | _ action: @Sendable (_ temporaryDirectory: AbsolutePath) async throws -> T 603 | ) async throws -> T { 604 | var temporaryDirectory: AbsolutePath! = nil 605 | var result: Result! 606 | do { 607 | temporaryDirectory = try await makeTemporaryDirectory(prefix: prefix) 608 | result = .success(try await action(temporaryDirectory)) 609 | } catch { 610 | result = .failure(error) 611 | } 612 | if let temporaryDirectory { 613 | try await remove(temporaryDirectory) 614 | } 615 | switch result! { 616 | case let .success(value): return value 617 | case let .failure(error): throw error 618 | } 619 | } 620 | 621 | public func fileSizeInBytes(at path: AbsolutePath) async throws -> Int64? { 622 | logger?.debug("Getting the size in bytes of file at path \(path.pathString).") 623 | guard let info = try await NIOFileSystem.FileSystem.shared.info( 624 | forFileAt: .init(path.pathString), 625 | infoAboutSymbolicLink: true 626 | ) else { return nil } 627 | return info.size 628 | } 629 | 630 | public func locateTraversingUp(from: AbsolutePath, relativePath: RelativePath) async throws -> AbsolutePath? { 631 | logger?.debug("Locating the relative path \(relativePath.pathString) by traversing up from \(from.pathString).") 632 | let path = from.appending(relativePath) 633 | if try await exists(path) { 634 | return path 635 | } 636 | if from == .root { return nil } 637 | return try await locateTraversingUp(from: from.parentDirectory, relativePath: relativePath) 638 | } 639 | 640 | public func createSymbolicLink(from: AbsolutePath, to: AbsolutePath) async throws { 641 | try await createSymbolicLink(fromPathString: from.pathString, toPathString: to.pathString) 642 | } 643 | 644 | public func createSymbolicLink(from: AbsolutePath, to: RelativePath) async throws { 645 | try await createSymbolicLink(fromPathString: from.pathString, toPathString: to.pathString) 646 | } 647 | 648 | private func createSymbolicLink(fromPathString: String, toPathString: String) async throws { 649 | logger?.debug("Creating symbolic link from \(fromPathString) to \(toPathString).") 650 | try await NIOFileSystem.FileSystem.shared.createSymbolicLink( 651 | at: FilePath(fromPathString), 652 | withDestination: FilePath(toPathString) 653 | ) 654 | } 655 | 656 | public func resolveSymbolicLink(_ symlinkPath: AbsolutePath) async throws -> AbsolutePath { 657 | logger?.debug("Resolving symbolink link at path \(symlinkPath.pathString).") 658 | if !(try await exists(symlinkPath)) { 659 | throw FileSystemError.absentSymbolicLink(symlinkPath) 660 | } 661 | guard let info = try await NIOFileSystem.FileSystem.shared.info( 662 | forFileAt: FilePath(symlinkPath.pathString), 663 | infoAboutSymbolicLink: true 664 | ) 665 | else { return symlinkPath } 666 | switch info.type { 667 | case .symlink: 668 | break 669 | default: 670 | return symlinkPath 671 | } 672 | let path = try await NIOFileSystem.FileSystem.shared.destinationOfSymbolicLink(at: FilePath(symlinkPath.pathString)) 673 | if path.starts(with: "/") { 674 | return try AbsolutePath(validating: path.string) 675 | } else { 676 | return AbsolutePath(symlinkPath.parentDirectory, try RelativePath(validating: path.string)) 677 | } 678 | } 679 | 680 | public func zipFileOrDirectoryContent(at path: Path.AbsolutePath, to: Path.AbsolutePath) async throws { 681 | logger?.debug("Zipping the file or contents of directory at path \(path.pathString) into \(to.pathString)") 682 | try await NIOSingletons.posixBlockingThreadPool.runIfActive { 683 | try FileManager.default.zipItem( 684 | at: URL(fileURLWithPath: path.pathString), 685 | to: URL(fileURLWithPath: to.pathString), 686 | shouldKeepParent: false 687 | ) 688 | } 689 | } 690 | 691 | public func unzip(_ zipPath: Path.AbsolutePath, to: Path.AbsolutePath) async throws { 692 | logger?.debug("Unzipping the file at path \(zipPath.pathString) to \(to.pathString)") 693 | try await NIOSingletons.posixBlockingThreadPool.runIfActive { 694 | try FileManager.default.unzipItem( 695 | at: URL(fileURLWithPath: zipPath.pathString), 696 | to: URL(fileURLWithPath: to.pathString) 697 | ) 698 | } 699 | } 700 | 701 | private func expandBraces(in regexString: String) throws -> [String] { 702 | let pattern = #"\{[^}]+\}"# 703 | let regex = try Regex(pattern) 704 | 705 | guard let match = regexString.firstMatch(of: regex) else { 706 | return [regexString] 707 | } 708 | 709 | return regexString[match.range] 710 | .dropFirst() 711 | .dropLast() 712 | .split(separator: ",") 713 | .map { option in 714 | regexString.replacingCharacters(in: match.range, with: option) 715 | } 716 | } 717 | 718 | public func glob(directory: Path.AbsolutePath, include: [String]) throws -> AnyThrowingAsyncSequenceable { 719 | let logMessage = 720 | "Looking up files and directories from \(directory.pathString) that match the glob patterns \(include.joined(separator: ", "))." 721 | logger?.debug("\(logMessage)") 722 | return Glob.search( 723 | directory: URL(string: directory.pathString)!, 724 | include: try include 725 | .flatMap { try expandBraces(in: $0) } 726 | .map { try Pattern($0) }, 727 | exclude: [ 728 | try Pattern("**/.DS_Store"), 729 | try Pattern("**/.gitkeep"), 730 | ], 731 | skipHiddenFiles: false 732 | ) 733 | .map { 734 | let path = $0.absoluteString.replacingOccurrences(of: "file://", with: "") 735 | return try Path 736 | .AbsolutePath( 737 | validating: path.removingPercentEncoding ?? path 738 | ) 739 | } 740 | .eraseToAnyThrowingAsyncSequenceable() 741 | } 742 | } 743 | 744 | extension AnyThrowingAsyncSequenceable where Element == Path.AbsolutePath { 745 | public func collect() async throws -> [Path.AbsolutePath] { 746 | try await reduce(into: [Path.AbsolutePath]()) { $0.append($1) } 747 | } 748 | } 749 | 750 | extension FilePath { 751 | fileprivate var path: AbsolutePath { 752 | try! AbsolutePath(validating: string) // swiftlint:disable:this force_try 753 | } 754 | } 755 | 756 | extension FileSystem { 757 | /// Creates and passes a temporary directory to the given action, coupling its lifecycle to the action's. 758 | /// - Parameter action: The action to run with the temporary directory. 759 | /// - Returns: Any value returned by the action. 760 | public func runInTemporaryDirectory( 761 | _ action: @Sendable (_ temporaryDirectory: AbsolutePath) async throws -> T 762 | ) async throws -> T { 763 | try await runInTemporaryDirectory(prefix: UUID().uuidString, action) 764 | } 765 | 766 | public func writeText(_ text: String, at path: AbsolutePath, options: Set) async throws { 767 | try await writeText(text, at: path, encoding: .utf8, options: options) 768 | } 769 | 770 | public func writeAsPlist(_ item: some Encodable, at path: AbsolutePath, options: Set) async throws { 771 | try await writeAsPlist(item, at: path, encoder: PropertyListEncoder(), options: options) 772 | } 773 | 774 | public func writeAsJSON(_ item: some Encodable, at path: Path.AbsolutePath, options: Set) async throws { 775 | try await writeAsJSON(item, at: path, encoder: JSONEncoder(), options: options) 776 | } 777 | } 778 | -------------------------------------------------------------------------------- /Sources/FileSystemTesting/FileSystemTestingTrait.swift: -------------------------------------------------------------------------------- 1 | #if canImport(Testing) 2 | import FileSystem 3 | import Path 4 | import Testing 5 | 6 | extension FileSystem { 7 | /// It returns the temporary directory created when using the `@Test(.inTemporaryDirectory)`. 8 | /// Note that since the value is only propagated through Swift structured concurrency, if you use DispatchQueue, 9 | /// values won't be propagated so you'll have to make sure they are explicitly passed down. 10 | @TaskLocal public static var temporaryTestDirectory: AbsolutePath? 11 | } 12 | 13 | public struct FileSystemTestingTrait: TestTrait, SuiteTrait, TestScoping { 14 | public func provideScope( 15 | for _: Test, 16 | testCase _: Test.Case?, 17 | performing function: @Sendable () async throws -> Void 18 | ) async throws { 19 | try await FileSystem().runInTemporaryDirectory { temporaryDirectory in 20 | try await FileSystem.$temporaryTestDirectory.withValue(temporaryDirectory) { 21 | try await function() 22 | } 23 | } 24 | } 25 | } 26 | 27 | extension Trait where Self == FileSystemTestingTrait { 28 | /// Creates a temporary directory and scopes its lifecycle to the lifecycle of the test. 29 | public static var inTemporaryDirectory: Self { Self() } 30 | } 31 | #endif 32 | -------------------------------------------------------------------------------- /Sources/Glob/GlobSearch.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// The result of a custom matcher for searching directory components 4 | public struct MatchResult { 5 | /// When true, the url will be added to the output 6 | var matches: Bool 7 | /// When true, the descendents of a directory will be skipped entirely 8 | /// 9 | /// This has no effect if the url is not a directory. 10 | var skipDescendents: Bool 11 | } 12 | 13 | /// Recursively search the contents of a directory, filtering by the provided patterns 14 | /// 15 | /// Searching is done asynchronously, with each subdirectory searched in parallel. Results are emitted as they are found. 16 | /// 17 | /// The results are returned as they are matched and do not have a consistent order to them. If you need the results sorted, wait 18 | /// for the entire search to complete and then sort the results. 19 | /// 20 | /// - Parameters: 21 | /// - baseURL: The directory to search, defaults to the current working directory. 22 | /// - include: When provided, only includes results that match these patterns. 23 | /// - exclude: When provided, ignore results that match these patterns. If a directory matches an exclude pattern, none of it's 24 | /// descendents will be matched. 25 | /// - keys: An array of keys that identify the properties that you want pre-fetched for each returned url. The values for these 26 | /// keys are cached in the corresponding URL objects. You may specify nil for this parameter. For a list of keys you can specify, 27 | /// see [Common File System Resource 28 | /// Keys](https://developer.apple.com/documentation/corefoundation/cfurl/common_file_system_resource_keys). 29 | /// - skipHiddenFiles: When true, hidden files will not be returned. 30 | /// - Returns: An async collection of urls. 31 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) 32 | // swiftlint:disable:next function_body_length 33 | public func search( 34 | // swiftformat:disable unusedArguments 35 | directory baseURL: URL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath), 36 | include: [Pattern] = [], 37 | exclude: [Pattern] = [], 38 | includingPropertiesForKeys keys: [URLResourceKey] = [], 39 | skipHiddenFiles: Bool = true 40 | ) -> AsyncThrowingStream { 41 | AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in 42 | let task = Task { 43 | do { 44 | for include in include { 45 | let (baseURL, include) = switch include.sections.first { 46 | case let .constant(constant): 47 | if constant.hasSuffix("/") { 48 | ( 49 | baseURL.appendingPath(constant.dropLast()), 50 | Pattern(sections: Array(include.sections.dropFirst()), options: include.options) 51 | ) 52 | } else if include.sections.count == 1 { 53 | ( 54 | baseURL.appendingPath(constant), 55 | Pattern(sections: Array(include.sections.dropFirst()), options: include.options) 56 | ) 57 | } else if case .componentWildcard = include.sections[1] { 58 | ( 59 | baseURL.appendingPath(constant.components(separatedBy: "/").dropLast().joined(separator: "/")), 60 | Pattern( 61 | sections: [.constant(constant.components(separatedBy: "/").last ?? "")] + 62 | Array(include.sections.dropFirst()), 63 | options: include.options 64 | ) 65 | ) 66 | } else { 67 | ( 68 | baseURL.appendingPath(constant), 69 | Pattern(sections: Array(include.sections.dropFirst()), options: include.options) 70 | ) 71 | } 72 | default: 73 | (baseURL, include) 74 | } 75 | 76 | if include.sections.isEmpty { 77 | if FileManager.default 78 | .fileExists(atPath: baseURL.absoluteString.removingPercentEncoding ?? baseURL.absoluteString) 79 | { 80 | continuation.yield(baseURL) 81 | } 82 | continue 83 | } 84 | 85 | let path = baseURL.absoluteString.removingPercentEncoding ?? baseURL.absoluteString 86 | let symbolicLinkDestination = URL.with(filePath: path).resolvingSymlinksInPath() 87 | var isDirectory: ObjCBool = false 88 | 89 | let symbolicLinkDestinationPath: String = symbolicLinkDestination 90 | .path() 91 | .removingPercentEncoding ?? symbolicLinkDestination.path() 92 | 93 | guard FileManager.default.fileExists( 94 | atPath: symbolicLinkDestinationPath, 95 | isDirectory: &isDirectory 96 | ), 97 | isDirectory.boolValue 98 | else { continue } 99 | 100 | try await search( 101 | directory: baseURL, 102 | symbolicLinkDestination: symbolicLinkDestination, 103 | matching: { _, relativePath in 104 | guard include.match(relativePath) else { 105 | // for patterns like `**/*.swift`, parent folders won't be matched but we don't want to skip those 106 | // folder's descendents or we won't find the files that do match 107 | let skipDescendents = !include.sections.enumerated().contains(where: { index, element in 108 | switch element { 109 | case .pathWildcard: 110 | return true 111 | case .componentWildcard: 112 | if index == include.sections.endIndex - 1 { 113 | return false 114 | } else if index == include.sections.endIndex - 2 { 115 | if case let .constant(constant) = include.sections.last { 116 | return constant.contains("/") 117 | } else { 118 | return true 119 | } 120 | } else { 121 | return true 122 | } 123 | default: 124 | return false 125 | } 126 | }) 127 | return .init(matches: false, skipDescendents: skipDescendents) 128 | } 129 | 130 | for pattern in exclude { 131 | if pattern.match(relativePath) { 132 | return .init(matches: false, skipDescendents: true) 133 | } 134 | } 135 | 136 | return .init(matches: true, skipDescendents: false) 137 | }, 138 | includingPropertiesForKeys: keys, 139 | skipHiddenFiles: skipHiddenFiles, 140 | relativePath: "", 141 | continuation: continuation 142 | ) 143 | } 144 | 145 | continuation.finish() 146 | } catch { 147 | continuation.finish(throwing: error) 148 | } 149 | } 150 | 151 | continuation.onTermination = { _ in 152 | task.cancel() 153 | } 154 | } 155 | } 156 | 157 | @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) 158 | private func search( 159 | directory: URL, 160 | symbolicLinkDestination: URL?, 161 | matching: @escaping @Sendable (_ url: URL, _ relativePath: String) throws -> MatchResult, 162 | includingPropertiesForKeys keys: [URLResourceKey], 163 | skipHiddenFiles: Bool, 164 | relativePath relativeDirectoryPath: String, 165 | continuation: AsyncThrowingStream.Continuation 166 | ) async throws { 167 | var options: FileManager.DirectoryEnumerationOptions = [ 168 | .producesRelativePathURLs, 169 | ] 170 | if skipHiddenFiles { 171 | options.insert(.skipsHiddenFiles) 172 | } 173 | let contents = try FileManager.default.contentsOfDirectory( 174 | at: symbolicLinkDestination ?? directory, 175 | includingPropertiesForKeys: keys + [.isDirectoryKey], 176 | options: options 177 | ) 178 | 179 | try await withThrowingTaskGroup(of: Void.self) { group in 180 | for url in contents { 181 | let relativePath = relativeDirectoryPath + url.lastPathComponent 182 | 183 | let matchResult = try matching(url, relativePath) 184 | 185 | let foundPath = directory.appendingPath(url.lastPathComponent) 186 | 187 | if matchResult.matches { 188 | continuation.yield(foundPath) 189 | } 190 | 191 | guard !matchResult.skipDescendents else { continue } 192 | 193 | let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) 194 | let isDirectory: Bool 195 | let symbolicLinkDestination: URL? 196 | if resourceValues.isDirectory == true { 197 | isDirectory = true 198 | symbolicLinkDestination = nil 199 | } else if resourceValues.isSymbolicLink == true { 200 | let resourceValues = try url.resolvingSymlinksInPath().resourceValues(forKeys: [.isDirectoryKey]) 201 | isDirectory = resourceValues.isDirectory == true 202 | symbolicLinkDestination = url.resolvingSymlinksInPath() 203 | } else { 204 | isDirectory = false 205 | symbolicLinkDestination = nil 206 | } 207 | if isDirectory { 208 | // This check prevents infinite loops when a symbolic link 209 | // points to an ancestor directory of the current path. 210 | if symbolicLinkDestination?.isAncestorOf(directory) != true { 211 | group.addTask { 212 | try await search( 213 | directory: foundPath, 214 | symbolicLinkDestination: symbolicLinkDestination, 215 | matching: matching, 216 | includingPropertiesForKeys: keys, 217 | skipHiddenFiles: skipHiddenFiles, 218 | relativePath: relativePath + "/", 219 | continuation: continuation 220 | ) 221 | } 222 | } 223 | } 224 | } 225 | 226 | try await group.waitForAll() 227 | } 228 | } 229 | 230 | extension URL { 231 | fileprivate func isAncestorOf(_ maybeChild: URL) -> Bool { 232 | let maybeChildFileURL = maybeChild.isFileURL ? maybeChild : .with(filePath: maybeChild.path) 233 | let maybeAncestorFileURL = isFileURL ? self : .with(filePath: path) 234 | 235 | do { 236 | let maybeChildResourceValues = try maybeChildFileURL.standardizedFileURL.resolvingSymlinksInPath() 237 | .resourceValues(forKeys: [.canonicalPathKey]) 238 | let maybeAncestorResourceValues = try maybeAncestorFileURL.standardizedFileURL.resolvingSymlinksInPath() 239 | .resourceValues(forKeys: [.canonicalPathKey]) 240 | 241 | if let canonicalChildPath = maybeChildResourceValues.canonicalPath, 242 | let canonicalAncestorPath = maybeAncestorResourceValues.canonicalPath 243 | { 244 | return canonicalChildPath.hasPrefix(canonicalAncestorPath) 245 | } 246 | return false 247 | } catch { 248 | return false 249 | } 250 | } 251 | } 252 | 253 | extension URL { 254 | public static func with(filePath: String) -> URL { 255 | #if os(Linux) 256 | return URL(fileURLWithPath: filePath) 257 | #else 258 | return URL(filePath: filePath) 259 | #endif 260 | } 261 | 262 | public func appendingPath(_ path: any StringProtocol) -> URL { 263 | #if os(Linux) 264 | return path 265 | .split(separator: "/") 266 | .reduce(self) { $0.appendingPathComponent(String($1)) } 267 | #else 268 | return appending(path: path) 269 | #endif 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /Sources/Glob/InvalidPattern.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct InvalidPatternError: Error { 4 | /// The pattern that was being parsed 5 | public var pattern: String 6 | /// The location in the pattern where the error was encountered 7 | public var location: String.Index 8 | 9 | /// The reason that parsing failed 10 | public var underlyingError: PatternParsingError 11 | } 12 | 13 | public enum PatternParsingError: Error { 14 | /// The range contained a lower bound button not an upper bound (ie "[a-]") 15 | case rangeNotClosed 16 | /// The range was ended without any content (ie "[]") 17 | case rangeIsEmpty 18 | /// The range included a separator but no lower bound (ie "[-c]") 19 | case rangeMissingBounds 20 | /// The upper bound of a range is lower than the lower bound 21 | case rangeBoundsAreOutOfOrder 22 | 23 | /// An escape was started without an actual escaped character because the escape was at the end of the pattern 24 | case invalidEscapeCharacter 25 | 26 | /// A character class (like `[:alnum:]`) was used with an unrecognized name 27 | case invalidNamedCharacterClass(String) 28 | 29 | case patternListNotClosed 30 | 31 | case emptyPatternList 32 | } 33 | -------------------------------------------------------------------------------- /Sources/Glob/Pattern+Match.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Substring { 4 | /// If the string has the given prefix, drop it and return the remaining string, otherwise return nil 5 | func dropPrefix(_ prefix: some StringProtocol) -> Substring? { 6 | if let range = range(of: prefix, options: .anchored) { 7 | self[range.upperBound...] 8 | } else { 9 | nil 10 | } 11 | } 12 | 13 | /// If the string has the given suffix, drop it and return the remaining string, otherwise return nil 14 | func dropSuffix(_ suffix: some StringProtocol) -> Substring? { 15 | if let range = range(of: suffix, options: [.anchored, .backwards]) { 16 | self[.. Bool { 28 | match(components: .init(sections), .init(name)) 29 | } 30 | 31 | // recursively matches against the pattern 32 | // swiftlint:disable:next function_body_length 33 | private func match( 34 | components: ArraySlice
, 35 | _ name: Substring 36 | ) -> Bool { 37 | // we use a loop to avoid unbounded recursion on arbitrarily long search strings 38 | // this method still recurses for wildcard matching, but it is bounded by the number of wildcards in the pattern, not the 39 | // size of the search string 40 | // the previous version that didn't use a loop would crash with search strings as small as 99 characters 41 | var components = components 42 | var name = name 43 | 44 | while true { 45 | if name.isEmpty { 46 | if components.isEmpty || components.allSatisfy(\.matchesEmptyContent) || [ 47 | .pathWildcard, 48 | .constant("/"), 49 | .componentWildcard, 50 | ] == components { 51 | return true 52 | } else { 53 | return false 54 | } 55 | } 56 | 57 | // this matches the value both from the beginning and the end, in order of what should be the most performant 58 | // matching at the beginning of a string is faster than iterating over the end of a string 59 | // matching constant length components is faster than wildcards 60 | // matching a component level wildcard is faster than a path level wildcard because it is more likely to find a limit 61 | 62 | // when matchLeadingDirectories is set, we can't match from the end 63 | 64 | switch (components.first, components.last, options.matchLeadingDirectories) { 65 | case let (.constant(constant), _, _): 66 | if let remaining = name.dropPrefix(constant) { 67 | components = components.dropFirst() 68 | name = remaining 69 | } else { 70 | return false 71 | } 72 | case (.singleCharacter, _, _): 73 | guard name.first != options.pathSeparator else { return false } 74 | if options.requiresExplicitLeadingPeriods, isAtStart(name), name.first == "." { 75 | return false 76 | } 77 | 78 | components = components.dropFirst() 79 | name = name.dropFirst() 80 | case let (.oneOf(ranges, isNegated: isNegated), _, _): 81 | if options.requiresExplicitLeadingPeriods, isAtStart(name), name.first == "." { 82 | // https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_13_01 83 | // It is unspecified whether an explicit in a bracket expression matching list, such as "[.abc]", can 84 | // match a leading in a filename. 85 | // in our implimentation, it will not match 86 | return false 87 | } 88 | 89 | guard let next = name.first, ranges.contains(where: { $0.contains(next) }) == !isNegated else { return false } 90 | 91 | components = components.dropFirst() 92 | name = name.dropFirst() 93 | case let (_, .constant(constant), false): 94 | if let remaining = name.dropSuffix(constant) { 95 | components = components.dropLast() 96 | name = remaining 97 | } else if [.pathWildcard, .constant("/")] == components.suffix(2) { 98 | components = components.dropLast(2) 99 | } else { 100 | switch components.last { 101 | case let .constant(constant): 102 | if constant.hasSuffix("/") { 103 | components = components.dropLast() + [.constant(String(constant.dropLast(1)))] 104 | } else if constant.hasPrefix("/"), components.first == .pathWildcard { 105 | components = [.constant(String(constant.dropFirst()))] 106 | } else { 107 | return false 108 | } 109 | default: 110 | return false 111 | } 112 | } 113 | case (_, .singleCharacter, false): 114 | guard name.last != options.pathSeparator else { return false } 115 | 116 | components = components.dropLast() 117 | name = name.dropLast() 118 | case let (_, .oneOf(ranges, isNegated: isNegated), false): 119 | guard let next = name.last, ranges.contains(where: { $0.contains(next) }) == !isNegated else { return false } 120 | 121 | components = components.dropLast() 122 | name = name.dropLast() 123 | case (.componentWildcard, _, _): 124 | if options.requiresExplicitLeadingPeriods, isAtStart(name), name.first == "." { 125 | return false 126 | } 127 | 128 | if components.count == 1 { 129 | if let pathSeparator = options.pathSeparator, !options.matchLeadingDirectories { 130 | // the last component is a component level wildcard, which matches anything except for the path separator 131 | return !name.contains(pathSeparator) 132 | } else { 133 | // no special treatment for path separators 134 | return true 135 | } 136 | } 137 | 138 | if match(components: components.dropFirst(), name) { 139 | return true 140 | } else { 141 | // components remain unchanged 142 | name = name.dropFirst() 143 | } 144 | case (_, .componentWildcard, false): 145 | if match(components: components.dropLast(), name) { 146 | return true 147 | } else { 148 | // components remain unchanged 149 | name = name.dropLast() 150 | } 151 | case (.pathWildcard, _, _): 152 | if components.count == 1 { 153 | // the last component is a path level wildcard, which matches anything 154 | return true 155 | } 156 | 157 | if match(components: components.dropFirst(), name) { 158 | return true 159 | } else { 160 | // components remain unchanged 161 | name = name.dropFirst() 162 | } 163 | case let (.patternList(style, subSections), _, _): 164 | let remaining = matchPatternListPrefix( 165 | components: components, 166 | name, 167 | style: style, 168 | subSections: subSections 169 | ) 170 | 171 | return remaining?.isEmpty == true 172 | case (.none, _, _): 173 | if options.matchLeadingDirectories, name.first == options.pathSeparator { 174 | return true 175 | } 176 | 177 | return name.isEmpty 178 | } 179 | } 180 | } 181 | 182 | /// Matches the beginning of the string and returns the rest. If a match cannot be made, returns nil. 183 | private func matchPrefix( 184 | components: ArraySlice
, 185 | _ name: Substring 186 | ) -> Substring? { 187 | if name.isEmpty { 188 | if components.isEmpty || components.allSatisfy(\.matchesEmptyContent) { 189 | return name.dropAll() 190 | } else { 191 | return nil 192 | } 193 | } 194 | 195 | switch components.first { 196 | case let .constant(constant): 197 | if let remaining = name.dropPrefix(constant) { 198 | return matchPrefix( 199 | components: components.dropFirst(), 200 | remaining 201 | ) 202 | } else { 203 | return nil 204 | } 205 | case .singleCharacter: 206 | guard name.first != options.pathSeparator else { return nil } 207 | if options.requiresExplicitLeadingPeriods, isAtStart(name) { 208 | return nil 209 | } 210 | 211 | return matchPrefix( 212 | components: components.dropFirst(), 213 | name.dropFirst() 214 | ) 215 | case let .oneOf(ranges, isNegated: isNegated): 216 | if options.requiresExplicitLeadingPeriods, isAtStart(name) { 217 | // https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_13_01 218 | // It is unspecified whether an explicit in a bracket expression matching list, such as "[.abc]", can 219 | // match a leading in a filename. 220 | // in our implimentation, it will not match 221 | return nil 222 | } 223 | 224 | guard let next = name.first, ranges.contains(where: { $0.contains(next) }) == !isNegated else { return nil } 225 | return matchPrefix( 226 | components: components.dropFirst(), 227 | name.dropFirst() 228 | ) 229 | case .componentWildcard: 230 | if options.requiresExplicitLeadingPeriods, isAtStart(name) { 231 | return nil 232 | } 233 | 234 | if components.count == 1 { 235 | if let pathSeparator = options.pathSeparator, !options.matchLeadingDirectories { 236 | // the last component is a component level wildcard, which matches anything except for the path separator 237 | if let index = name.firstIndex(of: pathSeparator) { 238 | return name.suffix(from: index) 239 | } else { 240 | return name.dropAll() 241 | } 242 | } else { 243 | // no special treatment for path separators 244 | return name.dropAll() 245 | } 246 | } 247 | 248 | if let remaining = matchPrefix(components: components.dropFirst(), name) { 249 | return remaining 250 | } else { 251 | return matchPrefix(components: components, name.dropFirst()) 252 | } 253 | case .pathWildcard: 254 | if components.count == 1 { 255 | // the last component is a path level wildcard, which matches anything 256 | return name.dropAll() 257 | } 258 | 259 | if let remaining = matchPrefix(components: components.dropFirst(), name) { 260 | return remaining 261 | } else { 262 | return matchPrefix(components: components, name.dropFirst()) 263 | } 264 | case let .patternList(style, subSections): 265 | return matchPatternListPrefix( 266 | components: components, 267 | name, 268 | style: style, 269 | subSections: subSections 270 | ) 271 | case .none: 272 | return name 273 | } 274 | } 275 | 276 | private func matchPatternListPrefix( 277 | components: ArraySlice
, 278 | _ name: Substring, 279 | style: Section.PatternListStyle, 280 | subSections: [[Section]] 281 | ) -> Substring? { 282 | for sectionsOption in subSections { 283 | if let remaining = matchPrefix( 284 | components: ArraySlice(sectionsOption + components.dropFirst()), 285 | name 286 | ) { 287 | // stop infinite recursion 288 | guard remaining != name else { return remaining } 289 | 290 | switch style { 291 | case .negated: 292 | return nil 293 | case .oneOrMore, .zeroOrMore: 294 | // switch to zeroOrMore since we've already fulfilled the "one" requirement 295 | return matchPrefix( 296 | components: [.patternList(.zeroOrMore, subSections)] + components.dropFirst(), 297 | remaining 298 | ) 299 | case .one, .zeroOrOne: 300 | // already matched "one", can't match any more 301 | return remaining 302 | } 303 | } 304 | } 305 | 306 | switch style { 307 | case .negated: 308 | return matchPrefix(components: [.pathWildcard] + components.dropFirst(), name) 309 | case .zeroOrMore, .zeroOrOne: 310 | return matchPrefix(components: components.dropFirst(), name) 311 | case .one, .oneOrMore: 312 | return nil 313 | } 314 | } 315 | 316 | private func isAtStart(_ name: Substring) -> Bool { 317 | name.startIndex == name.base.startIndex || name.previous() == options.pathSeparator 318 | } 319 | } 320 | 321 | extension Substring { 322 | /// Returns the character just before the substring starts 323 | fileprivate func previous() -> Character? { 324 | guard startIndex > base.startIndex else { return nil } 325 | let index = base.index(before: startIndex) 326 | 327 | return base[index] 328 | } 329 | 330 | /// returns an empty substring preserving endIndex 331 | fileprivate func dropAll() -> Substring { 332 | suffix(0) 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /Sources/Glob/Pattern+Options.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Pattern { 4 | /// Options to control how patterns are parsed and matched 5 | public struct Options: Equatable, Sendable { 6 | /// If a double star/asterisk causes the pattern to match path separators. 7 | /// 8 | /// If `pathSeparator` is `nil` this has no effect. 9 | public var supportsPathLevelWildcards: Bool = true 10 | 11 | /// How empty ranges (`[]`) are treated 12 | public enum EmptyRangeBehavior: Sendable { 13 | /// Treat an empty range as matching nothing, equivalent to nothing at all 14 | case allow 15 | /// Throw an error when an empty range is used 16 | case error 17 | /// When a range starts with a closing range character, treat the closing bracket as a character and continue the 18 | /// range 19 | case treatClosingBracketAsCharacter 20 | } 21 | 22 | /// How are empty ranges handled. 23 | public var emptyRangeBehavior: EmptyRangeBehavior = .error 24 | 25 | /// If the pattern supports escaping control characters with '\' 26 | /// 27 | /// When true, a backslash character ( '\' ) in pattern followed by any other character shall match that second character 28 | /// in string. In particular, "\\" shall match a backslash in string. Otherwise a backslash character shall be treated as 29 | /// an ordinary character. 30 | public var supportsEscapedCharacters: Bool = true 31 | 32 | /// Allows the `-` character to be included in a character class if it is the first or last character (ie `[-abc]` or 33 | /// `[abc-]`) 34 | public var supportsRangeSeparatorAtBeginningAndEnd: Bool = true 35 | 36 | /// If a period in the name is at the beginning of a component, don't match using wildcards. 37 | /// 38 | /// Treat the `.` character specially if it appears at the beginning of string. If this flag is set, wildcard constructs 39 | /// in pattern cannot match `.` as the first character of string. If you set both this and `pathSeparator`, then the 40 | /// special treatment applies to `.` following `pathSeparator` as well as to `.` at the beginning of string. 41 | /// 42 | /// Equivalent to `FNM_PERIOD`. 43 | public var requiresExplicitLeadingPeriods: Bool = true 44 | 45 | /// If a pattern should match if it matches a parent directory, as defined by `pathSeparator`. 46 | /// 47 | /// Ignore a trailing sequence of characters starting with a `/` in string; that is to say, test whether string starts 48 | /// with a directory name that pattern matches. If this flag is set, either `foo*` or `foobar` as a pattern would match 49 | /// the string `foobar/frobozz`. Equivalent to `FNM_LEADING_DIR`.` 50 | /// 51 | /// If `pathSeparator` is `nil` this has no effect. 52 | public var matchLeadingDirectories: Bool = false 53 | 54 | /// Recognize beside the normal patterns also the extended patterns introduced in `ksh`. Equivalent to `FNM_EXTMATCH`. 55 | /// 56 | /// The patterns are written in the form explained in the following table where pattern-list is a | separated list of 57 | /// patterns. 58 | /// 59 | /// - ?(pattern-list) 60 | /// The pattern matches if zero or one occurences of any of the patterns in the pattern-list allow matching the input 61 | /// string. 62 | /// - *(pattern-list) 63 | /// The pattern matches if zero or more occurences of any of the patterns in the pattern-list allow matching the input 64 | /// string. 65 | /// - +(pattern-list) 66 | /// The pattern matches if one or more occurences of any of the patterns in the pattern-list allow matching the input 67 | /// string. 68 | /// - @(pattern-list) 69 | /// The pattern matches if exactly one occurence of any of the patterns in the pattern-list allows matching the input 70 | /// string. 71 | /// - !(pattern-list) 72 | /// The pattern matches if the input string cannot be matched with any of the patterns in the pattern-list. 73 | public var supportsPatternLists: Bool = true 74 | 75 | /// The character used to invert a character class. 76 | public enum RangeNegationCharacter: Equatable, Sendable { 77 | /// Use the `!` character to denote an inverse character class. 78 | case exclamationMark 79 | /// Use the `^` character to denote an inverse character class. 80 | case caret 81 | } 82 | 83 | /// The character used to specify when a range matches characters that aren't in the range. 84 | public var rangeNegationCharacter: RangeNegationCharacter = .exclamationMark 85 | 86 | /// The path separator to use in matching 87 | /// 88 | /// If this is `nil`, path separators have no special meaning. This is equivalent to excluding `FNM_PATHNAME` in 89 | /// `fnmatch`. 90 | /// 91 | /// Defaults to "/" regardless of operating system. 92 | public var pathSeparator: Character? = "/" 93 | 94 | /// Default options for parsing and matching patterns. 95 | public static let `default`: Self = .init( 96 | supportsPathLevelWildcards: true, 97 | emptyRangeBehavior: .error 98 | ) 99 | 100 | /// Attempts to match the behavior of [`filepath.Match` in go](https://pkg.go.dev/path/filepath#Match). 101 | public static let go: Self = Options( 102 | supportsPathLevelWildcards: false, 103 | emptyRangeBehavior: .error, 104 | supportsRangeSeparatorAtBeginningAndEnd: false, 105 | rangeNegationCharacter: .caret 106 | ) 107 | 108 | /// Attempts to match the behavior of [POSIX glob](https://man7.org/linux/man-pages/man7/glob.7.html). 109 | /// - Returns: Options to use to create a Pattern. 110 | public static func posix() -> Self { 111 | Options( 112 | supportsPathLevelWildcards: false, 113 | emptyRangeBehavior: .allow, 114 | requiresExplicitLeadingPeriods: true 115 | ) 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /Sources/Glob/Pattern+Parser.swift: -------------------------------------------------------------------------------- 1 | extension Pattern { 2 | struct Parser { 3 | private var pattern: Substring 4 | let options: Options 5 | 6 | init(pattern: some StringProtocol, options: Options) { 7 | self.pattern = Substring(pattern) 8 | self.options = options 9 | } 10 | 11 | enum Token: Equatable { 12 | case character(Character) 13 | case leftSquareBracket // [ 14 | case rightSquareBracket // ] 15 | case questionMark // ? 16 | case dash // - 17 | case asterisk // * 18 | case colon // : 19 | case leftParen // ( 20 | case rightParen // ) 21 | case verticalLine // | 22 | case at // @ 23 | case plus // + 24 | case exclamationMark // ! 25 | case caret // ^ 26 | 27 | init(_ character: Character) { 28 | switch character { 29 | case "]": 30 | self = .rightSquareBracket 31 | case "[": 32 | self = .leftSquareBracket 33 | case "?": 34 | self = .questionMark 35 | case "-": 36 | self = .dash 37 | case "*": 38 | self = .asterisk 39 | case ":": 40 | self = .colon 41 | case "(": 42 | self = .leftParen 43 | case ")": 44 | self = .rightParen 45 | case "|": 46 | self = .verticalLine 47 | case "@": 48 | self = .at 49 | case "+": 50 | self = .plus 51 | case "!": 52 | self = .exclamationMark 53 | case "^": 54 | self = .caret 55 | default: 56 | self = .character(character) 57 | } 58 | } 59 | 60 | var character: Character { 61 | switch self { 62 | case let .character(character): 63 | character 64 | case .leftSquareBracket: 65 | "[" 66 | case .rightSquareBracket: 67 | "]" 68 | case .questionMark: 69 | "?" 70 | case .dash: 71 | "-" 72 | case .asterisk: 73 | "*" 74 | case .colon: 75 | ":" 76 | case .leftParen: 77 | "(" 78 | case .rightParen: 79 | ")" 80 | case .verticalLine: 81 | "|" 82 | case .at: 83 | "@" 84 | case .plus: 85 | "+" 86 | case .exclamationMark: 87 | "!" 88 | case .caret: 89 | "^" 90 | } 91 | } 92 | } 93 | 94 | mutating func pop(_ condition: (Token) -> Bool = { _ in true }) throws -> Token? { 95 | if let next = pattern.first { 96 | let updatedPattern = pattern.dropFirst() 97 | 98 | if options.supportsEscapedCharacters, next == .escape { 99 | guard let escaped = updatedPattern.first else { throw PatternParsingError.invalidEscapeCharacter } 100 | 101 | guard condition(.character(escaped)) else { return nil } 102 | 103 | pattern = updatedPattern.dropFirst() 104 | return .character(escaped) 105 | } else { 106 | let token = Token(next) 107 | 108 | guard condition(token) else { return nil } 109 | 110 | pattern = updatedPattern 111 | return token 112 | } 113 | } 114 | 115 | return nil 116 | } 117 | 118 | mutating func pop(_ token: Token) throws -> Bool { 119 | try pop { $0 == token } != nil 120 | } 121 | 122 | mutating func parse() throws -> Pattern { 123 | do { 124 | let sections = try parseSections() 125 | 126 | return Pattern(sections: sections, options: options) 127 | } catch let error as PatternParsingError { 128 | // add information about where the error was encountered by including the original pattern and our current 129 | // location 130 | throw InvalidPatternError( 131 | pattern: pattern.base, 132 | location: pattern.startIndex, 133 | underlyingError: error 134 | ) 135 | } 136 | } 137 | 138 | // swiftlint:disable:next function_body_length 139 | private mutating func parseSections(delimeters: some Collection = EmptyCollection()) throws -> [Section] { 140 | var sections: [Section] = [] 141 | 142 | while let next = try pop({ !delimeters.contains($0) }) { 143 | switch next { 144 | case .asterisk: 145 | if options.supportsPatternLists, let sectionList = try parsePatternList() { 146 | sections.append(.patternList(.zeroOrMore, sectionList)) 147 | } else if sections.last == .componentWildcard { 148 | if options.supportsPathLevelWildcards { 149 | sections[sections.endIndex - 1] = .pathWildcard 150 | } else { 151 | break // ignore repeated wildcards 152 | } 153 | } else if sections.last == .pathWildcard { 154 | break // ignore repeated wildcards 155 | } else { 156 | sections.append(.componentWildcard) 157 | } 158 | case .questionMark: 159 | if options.supportsPatternLists, let sectionList = try parsePatternList() { 160 | sections.append(.patternList(.zeroOrOne, sectionList)) 161 | } else { 162 | sections.append(.singleCharacter) 163 | } 164 | case .at: 165 | if options.supportsPatternLists, let sectionsList = try parsePatternList() { 166 | sections.append(.patternList(.one, sectionsList)) 167 | } else { 168 | sections.append(constant: next.character) 169 | } 170 | case .plus: 171 | if options.supportsPatternLists, let sectionsList = try parsePatternList() { 172 | sections.append(.patternList(.oneOrMore, sectionsList)) 173 | } else { 174 | sections.append(constant: next.character) 175 | } 176 | case .exclamationMark: 177 | if options.supportsPatternLists, let sectionsList = try parsePatternList() { 178 | sections.append(.patternList(.negated, sectionsList)) 179 | } else { 180 | sections.append(constant: next.character) 181 | } 182 | case .leftSquareBracket: 183 | let negated: Bool 184 | if try pop(options.rangeNegationCharacter.token) { 185 | negated = true 186 | } else { 187 | negated = false 188 | } 189 | 190 | var ranges: [CharacterClass] = [] 191 | 192 | if options.emptyRangeBehavior == .treatClosingBracketAsCharacter, try pop(.rightSquareBracket) { 193 | // https://man7.org/linux/man-pages/man7/glob.7.html 194 | // The string enclosed by the brackets cannot be empty; therefore ']' can be allowed between the brackets, 195 | // provided that it is the first character. 196 | ranges.append(.character("]")) 197 | } 198 | 199 | loop: while true { 200 | guard let next = try pop() else { 201 | throw PatternParsingError.rangeNotClosed 202 | } 203 | 204 | switch next { 205 | case .rightSquareBracket: 206 | break loop 207 | case .leftSquareBracket: 208 | if try pop(.colon) { 209 | // Named character classes 210 | guard let endIndex = pattern.firstIndex(of: ":") else { throw PatternParsingError.rangeNotClosed } 211 | 212 | let name = pattern.prefix(upTo: endIndex) 213 | 214 | if let name = CharacterClass.Name(rawValue: String(name)) { 215 | ranges.append(.named(name)) 216 | pattern = pattern[endIndex...].dropFirst() 217 | 218 | if try !pop(.rightSquareBracket) { 219 | throw PatternParsingError.rangeNotClosed 220 | } 221 | } else { 222 | throw PatternParsingError.invalidNamedCharacterClass(String(name)) 223 | } 224 | } else { 225 | ranges.append(.character("[")) 226 | } 227 | case .dash: 228 | if !options.supportsRangeSeparatorAtBeginningAndEnd { 229 | throw PatternParsingError.rangeMissingBounds 230 | } 231 | 232 | // https://man7.org/linux/man-pages/man7/glob.7.html 233 | // One may include '-' in its literal meaning by making it the first or last character between the 234 | // brackets. 235 | ranges.append(.character("-")) 236 | default: 237 | if try pop(.dash) { 238 | if try pop(.rightSquareBracket) { 239 | if !options.supportsRangeSeparatorAtBeginningAndEnd { 240 | throw PatternParsingError.rangeNotClosed 241 | } 242 | 243 | // `-` is the last character in the group, treat it as a character 244 | // https://man7.org/linux/man-pages/man7/glob.7.html 245 | // One may include '-' in its literal meaning by making it the first or last character between 246 | // the brackets. 247 | ranges.append(.character(next.character)) 248 | ranges.append(.character("-")) 249 | 250 | break loop 251 | } else { 252 | // this is a range like a-z, find the upper limit of the range 253 | guard let upper = try pop() 254 | else { throw PatternParsingError.rangeNotClosed } 255 | 256 | guard next.character <= upper.character 257 | else { throw PatternParsingError.rangeBoundsAreOutOfOrder } 258 | ranges.append(.range(next.character ... upper.character)) 259 | } 260 | } else { 261 | ranges.append(.character(next.character)) 262 | } 263 | } 264 | } 265 | 266 | guard !ranges.isEmpty else { 267 | if options.emptyRangeBehavior == .error { 268 | throw PatternParsingError.rangeIsEmpty 269 | } else { 270 | break 271 | } 272 | } 273 | 274 | sections.append(.oneOf(ranges, isNegated: negated)) 275 | case let .character(character): 276 | sections.append(constant: character) 277 | case .rightSquareBracket, .dash, .colon, .leftParen, .rightParen, .verticalLine, .caret: 278 | sections.append(constant: next.character) 279 | } 280 | } 281 | 282 | return sections 283 | } 284 | 285 | /// Parses a pattern list like `(abc|xyz)` 286 | mutating func parsePatternList() throws -> [[Section]]? { 287 | if options.supportsPatternLists, try pop(.leftParen) { 288 | // start of pattern list 289 | var sectionsList: [[Section]] = [] 290 | 291 | loop: while !pattern.isEmpty { 292 | let subSections = try parseSections(delimeters: [.verticalLine, .rightParen]) 293 | 294 | sectionsList.append(subSections) 295 | 296 | switch try pop() { 297 | case .verticalLine: 298 | continue 299 | case .rightParen: 300 | break loop 301 | default: 302 | throw PatternParsingError.patternListNotClosed 303 | } 304 | } 305 | 306 | guard !sectionsList.isEmpty else { 307 | throw PatternParsingError.emptyPatternList 308 | } 309 | 310 | return sectionsList 311 | } else { 312 | return nil 313 | } 314 | } 315 | } 316 | } 317 | 318 | extension [Pattern.Section] { 319 | fileprivate mutating func append(constant character: Character) { 320 | if case let .constant(value) = last { 321 | self[endIndex - 1] = .constant(value + String(character)) 322 | } else { 323 | append(.constant(String(character))) 324 | } 325 | } 326 | } 327 | 328 | extension Pattern.Options.RangeNegationCharacter { 329 | var token: Pattern.Parser.Token { 330 | switch self { 331 | case .exclamationMark: 332 | .exclamationMark 333 | case .caret: 334 | .caret 335 | } 336 | } 337 | } 338 | -------------------------------------------------------------------------------- /Sources/Glob/Pattern.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Character { 4 | static let escape: Character = #"\"# 5 | } 6 | 7 | /// A glob pattern that can be matched against string content. 8 | public struct Pattern: Equatable, Sendable { 9 | public enum Section: Equatable, Sendable { 10 | /// A wildcard that matches any 0 or more characters except for the path separator ("/" by default) 11 | case componentWildcard 12 | /// A wildcard that matches any 0 or more characters 13 | case pathWildcard 14 | 15 | /// Matches an exact string 16 | case constant(String) 17 | /// Matches any single character 18 | case singleCharacter 19 | /// Matches a single character in any of the given ranges 20 | /// 21 | /// A range may be a single character (ie "a"..."a"). For instance the pattern [abc] will create 3 ranges that are each a 22 | /// single character. 23 | case oneOf([CharacterClass], isNegated: Bool) 24 | 25 | public enum PatternListStyle: Equatable, Sendable { 26 | case zeroOrOne 27 | case zeroOrMore 28 | case oneOrMore 29 | case one 30 | case negated 31 | 32 | var allowsZero: Bool { 33 | switch self { 34 | case .zeroOrOne, .zeroOrMore, .negated: 35 | true 36 | case .oneOrMore, .one: 37 | false 38 | } 39 | } 40 | 41 | var allowsMultiple: Bool { 42 | switch self { 43 | case .oneOrMore, .zeroOrMore: 44 | true 45 | case .zeroOrOne, .one, .negated: 46 | false 47 | } 48 | } 49 | } 50 | 51 | case patternList(_ style: PatternListStyle, _ sections: [[Section]]) 52 | 53 | /// If the section can match a variable length of characters 54 | /// 55 | /// When false, the section represents a fixed length match. 56 | public var matchesEmptyContent: Bool { 57 | switch self { 58 | case .constant, .singleCharacter, .oneOf: 59 | false 60 | case .componentWildcard, .pathWildcard: 61 | true 62 | case let .patternList(style, subSections): 63 | switch style { 64 | case .negated, .zeroOrOne, .zeroOrMore: 65 | true 66 | case .one, .oneOrMore: 67 | subSections.contains(where: { subSection in 68 | subSection.allSatisfy { section in 69 | section.matchesEmptyContent 70 | } 71 | }) 72 | } 73 | } 74 | } 75 | } 76 | 77 | public enum CharacterClass: Equatable, Sendable { 78 | case range(ClosedRange) 79 | 80 | public enum Name: String, Equatable, Sendable { 81 | // https://man7.org/linux/man-pages/man7/glob.7.html 82 | // https://www.domaintools.com/resources/user-guides/dnsdb-glob-reference-guide/ 83 | 84 | /// Alphanumeric characters 0-9, A-Z, and a-z 85 | case alphaNumeric = "alnum" 86 | /// Alphabetic characters A-Z, a-z 87 | case alpha 88 | /// Blank characters (space and tab) 89 | case blank 90 | /// Control characters 91 | case control = "cntrl" 92 | /// Decimal digits 0-9 93 | case numeric = "digit" 94 | /// Any printable character other than space. 95 | case graph 96 | /// Lower case alphabetic characters a-z 97 | case lower 98 | /// Any printable character 99 | case printable = "print" 100 | /// Printable characters other than space and `alphaNumeric` 101 | case punctuation = "punct" 102 | /// Any whitespace character 103 | case space 104 | /// Upper case alphabetic characters A-Z 105 | case upper 106 | /// Hexadecimal digits 0-9, a-f, A-F 107 | case hexadecimalDigit = "xdigit" 108 | 109 | public func contains(_ character: Character) -> Bool { 110 | switch self { 111 | case .alphaNumeric: 112 | character.isLetter || character.isNumber 113 | case .alpha: 114 | character.isLetter 115 | case .blank: 116 | character.isWhitespace 117 | case .control: 118 | character.unicodeScalars 119 | .allSatisfy { $0.properties.generalCategory == .control } 120 | case .numeric: 121 | character.isNumber 122 | case .graph: 123 | character != " " && character.unicodeScalars 124 | .allSatisfy(\.properties.generalCategory.isPrintable) 125 | case .lower: 126 | character.isLowercase 127 | case .printable: 128 | character.unicodeScalars 129 | .allSatisfy(\.properties.generalCategory.isPrintable) 130 | case .punctuation: 131 | character.isPunctuation 132 | case .space: 133 | character.isWhitespace 134 | case .upper: 135 | character.isUppercase 136 | case .hexadecimalDigit: 137 | character.isHexDigit 138 | } 139 | } 140 | } 141 | 142 | case named(Name) 143 | 144 | static func character(_ character: Character) -> Self { 145 | .range(character ... character) 146 | } 147 | 148 | public func contains(_ character: Character) -> Bool { 149 | switch self { 150 | case let .range(closedRange): 151 | closedRange.contains(character) 152 | case let .named(name): 153 | name.contains(character) 154 | } 155 | } 156 | } 157 | 158 | /// The individual parts of the pattern to match against 159 | public var sections: [Section] 160 | /// Options used for parsing and matching 161 | public var options: Options 162 | 163 | init(sections: [Section], options: Options) { 164 | self.sections = sections 165 | self.options = options 166 | } 167 | 168 | /// Parses a pattern string into a reusable pattern 169 | public init( 170 | _ pattern: some StringProtocol, 171 | options: Options = .default 172 | ) throws { 173 | var parser = Parser(pattern: pattern, options: options) 174 | self = try parser.parse() 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /Sources/Glob/Unicode.GeneralCategory+Helpers.swift: -------------------------------------------------------------------------------- 1 | extension Unicode.GeneralCategory { 2 | var isPrintable: Bool { 3 | switch self { 4 | case .uppercaseLetter, .lowercaseLetter, .titlecaseLetter, .modifierLetter, .otherLetter, .nonspacingMark, .spacingMark, 5 | .enclosingMark, .decimalNumber, .letterNumber, .otherNumber, .connectorPunctuation, .dashPunctuation, 6 | .openPunctuation, 7 | .closePunctuation, .initialPunctuation, .finalPunctuation, .otherPunctuation, .spaceSeparator, .lineSeparator, 8 | .paragraphSeparator, .surrogate, .privateUse, .unassigned, .mathSymbol, .currencySymbol, .modifierSymbol, 9 | .otherSymbol: 10 | true 11 | case .control, .format: 12 | false 13 | @unknown default: 14 | true 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Tests/FileSystemTestingTests/FileSystemTestingTraitTests.swift: -------------------------------------------------------------------------------- 1 | import FileSystem 2 | import FileSystemTesting 3 | import Testing 4 | 5 | struct FileSystemTestingTraitTests { 6 | @Test(.inTemporaryDirectory) func testTemporaryDirectory() async throws { 7 | // Given 8 | let temporaryDirectory = try #require(FileSystem.temporaryTestDirectory) 9 | let filePath = temporaryDirectory.appending(component: "test") 10 | 11 | // Then 12 | try await FileSystem().touch(filePath) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Tests/FileSystemTests/FileSystemTests.swift: -------------------------------------------------------------------------------- 1 | import Path 2 | import XCTest 3 | @testable import FileSystem 4 | 5 | private struct TestError: Error, Equatable {} 6 | 7 | final class FileSystemTests: XCTestCase, @unchecked Sendable { 8 | var subject: FileSystem! 9 | 10 | override func setUp() async throws { 11 | try await super.setUp() 12 | subject = FileSystem() 13 | } 14 | 15 | override func tearDown() async throws { 16 | subject = nil 17 | try await super.tearDown() 18 | } 19 | 20 | func test_createTemporaryDirectory_returnsAValidDirectory() async throws { 21 | // Given 22 | let temporaryDirectory = try await subject.makeTemporaryDirectory(prefix: "FileSystem") 23 | 24 | // When 25 | let exists = try await subject.exists(temporaryDirectory) 26 | XCTAssertTrue(exists) 27 | let firstExists = try await subject.exists(temporaryDirectory, isDirectory: true) 28 | XCTAssertTrue(firstExists) 29 | let secondExists = try await subject.exists(temporaryDirectory, isDirectory: false) 30 | XCTAssertFalse(secondExists) 31 | } 32 | 33 | func test_runInTemporaryDirectory_removesTheDirectoryAfterSuccessfulCompletion() async throws { 34 | // Given/When 35 | let temporaryDirectory = try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 36 | try await subject.touch(temporaryDirectory.appending(component: "test")) 37 | return temporaryDirectory 38 | } 39 | 40 | // Then 41 | let exists = try await subject.exists(temporaryDirectory) 42 | XCTAssertFalse(exists) 43 | } 44 | 45 | func test_runInTemporaryDirectory_rethrowsErrors() async throws { 46 | // Given/When 47 | var caughtError: Error? 48 | do { 49 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { _ in 50 | throw TestError() 51 | } 52 | } catch { 53 | caughtError = error 54 | } 55 | 56 | // Then 57 | XCTAssertEqual(caughtError as? TestError, TestError()) 58 | } 59 | 60 | func test_currentWorkingDirectory() async throws { 61 | // When 62 | let got = try await subject.currentWorkingDirectory() 63 | 64 | // Then 65 | let isDirectory = try await subject.exists(got, isDirectory: true) 66 | XCTAssertTrue(isDirectory) 67 | } 68 | 69 | func test_move_when_fromFileExistsAndToPathsParentDirectoryExists() async throws { 70 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 71 | // Given 72 | let fromFilePath = temporaryDirectory.appending(component: "from") 73 | try await subject.touch(fromFilePath) 74 | let toFilePath = temporaryDirectory.appending(component: "to") 75 | 76 | // When 77 | try await subject.move(from: fromFilePath, to: toFilePath) 78 | 79 | // Then 80 | let exists = try await subject.exists(toFilePath) 81 | XCTAssertTrue(exists) 82 | } 83 | } 84 | 85 | func test_move_throwsAMoveNotFoundError_when_fromFileDoesntExist() async throws { 86 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 87 | // Given 88 | let fromFilePath = temporaryDirectory.appending(component: "from") 89 | let toFilePath = temporaryDirectory.appending(component: "to") 90 | 91 | // When 92 | var _error: FileSystemError? 93 | do { 94 | try await subject.move(from: fromFilePath, to: toFilePath) 95 | } catch { 96 | _error = error as? FileSystemError 97 | } 98 | 99 | // Then 100 | XCTAssertEqual(_error, FileSystemError.moveNotFound(from: fromFilePath, to: toFilePath)) 101 | } 102 | } 103 | 104 | func test_makeDirectory_createsTheDirectory() async throws { 105 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 106 | // Given 107 | let directoryPath = temporaryDirectory.appending(component: "to") 108 | 109 | // When 110 | try await subject.makeDirectory(at: directoryPath) 111 | 112 | // Then 113 | let exists = try await subject.exists(directoryPath) 114 | XCTAssertTrue(exists) 115 | } 116 | } 117 | 118 | func test_makeDirectory_createsTheParentDirectories() async throws { 119 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 120 | // Given 121 | let directoryPath = temporaryDirectory.appending(component: "first").appending(component: "second") 122 | 123 | // When 124 | try await subject.makeDirectory(at: directoryPath) 125 | 126 | // Then 127 | let exists = try await subject.exists(directoryPath) 128 | XCTAssertTrue(exists) 129 | } 130 | } 131 | 132 | func test_makeDirectory_throwsAnError_when_parentDirectoryDoesntExist() async throws { 133 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 134 | // Given 135 | let directoryPath = temporaryDirectory.appending(component: "first").appending(component: "second") 136 | 137 | // When 138 | var _error: FileSystemError? 139 | do { 140 | try await subject.makeDirectory(at: directoryPath, options: []) 141 | } catch { 142 | _error = error as? FileSystemError 143 | } 144 | 145 | // Then 146 | XCTAssertEqual(_error, FileSystemError.makeDirectoryAbsentParent(directoryPath)) 147 | } 148 | } 149 | 150 | func test_writeTextFile_and_readTextFile_returnsTheContent() async throws { 151 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 152 | // Given 153 | let filePath = temporaryDirectory.appending(component: "file") 154 | try await subject.writeText("test", at: filePath) 155 | 156 | // When 157 | let got = try await subject.readTextFile(at: filePath) 158 | 159 | // Then 160 | XCTAssertEqual(got, "test") 161 | } 162 | } 163 | 164 | func test_writeTextFile_and_readTextFile_returnsTheContent_when_whenOverwritingFile() async throws { 165 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 166 | // Given 167 | let filePath = temporaryDirectory.appending(component: "file") 168 | try await subject.writeText("test", at: filePath, options: Set([.overwrite])) 169 | try await subject.writeText("test", at: filePath, options: Set([.overwrite])) 170 | 171 | // When 172 | let got = try await subject.readTextFile(at: filePath) 173 | 174 | // Then 175 | XCTAssertEqual(got, "test") 176 | } 177 | } 178 | 179 | func test_writeAsJSON_and_readJSONFile_returnsTheContent() async throws { 180 | struct CodableStruct: Codable, Equatable { let name: String } 181 | 182 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 183 | // Given 184 | let item = CodableStruct(name: "tuist") 185 | let filePath = temporaryDirectory.appending(component: "file") 186 | try await subject.writeAsJSON(item, at: filePath) 187 | 188 | // When 189 | let got: CodableStruct = try await subject.readJSONFile(at: filePath) 190 | 191 | // Then 192 | XCTAssertEqual(got, item) 193 | } 194 | } 195 | 196 | func test_writeAsJSON_and_readJSONFile_returnsTheContent_when_whenOverwritingFile() async throws { 197 | struct CodableStruct: Codable, Equatable { let name: String } 198 | 199 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 200 | // Given 201 | let item = CodableStruct(name: "tuist") 202 | let filePath = temporaryDirectory.appending(component: "file") 203 | try await subject.writeAsJSON(item, at: filePath, options: Set([.overwrite])) 204 | try await subject.writeAsJSON(item, at: filePath, options: Set([.overwrite])) 205 | 206 | // When 207 | let got: CodableStruct = try await subject.readJSONFile(at: filePath) 208 | 209 | // Then 210 | XCTAssertEqual(got, item) 211 | } 212 | } 213 | 214 | func test_writeAsPlist_and_readPlistFile_returnsTheContent() async throws { 215 | struct CodableStruct: Codable, Equatable { let name: String } 216 | 217 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 218 | // Given 219 | let item = CodableStruct(name: "tuist") 220 | let filePath = temporaryDirectory.appending(component: "file") 221 | try await subject.writeAsPlist(item, at: filePath) 222 | 223 | // When 224 | let got: CodableStruct = try await subject.readPlistFile(at: filePath) 225 | 226 | // Then 227 | XCTAssertEqual(got, item) 228 | } 229 | } 230 | 231 | func test_writeAsPlist_and_readPlistFile_returnsTheContent_when_overridingFile() async throws { 232 | struct CodableStruct: Codable, Equatable { let name: String } 233 | 234 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 235 | // Given 236 | let item = CodableStruct(name: "tuist") 237 | let filePath = temporaryDirectory.appending(component: "file") 238 | try await subject.writeAsPlist(item, at: filePath, options: Set([.overwrite])) 239 | try await subject.writeAsPlist(item, at: filePath, options: Set([.overwrite])) 240 | 241 | // When 242 | let got: CodableStruct = try await subject.readPlistFile(at: filePath) 243 | 244 | // Then 245 | XCTAssertEqual(got, item) 246 | } 247 | } 248 | 249 | func test_fileSizeInBytes_returnsTheFileSize_when_itExists() async throws { 250 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 251 | // Given 252 | let path = temporaryDirectory.appending(component: "file") 253 | try await subject.writeText("tuist", at: path) 254 | 255 | // When 256 | let size = try await subject.fileSizeInBytes(at: path) 257 | 258 | // Then 259 | XCTAssertEqual(size, 5) 260 | } 261 | } 262 | 263 | func test_fileSizeInBytes_returnsNil_when_theFileDoesntExist() async throws { 264 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 265 | // Given 266 | let path = temporaryDirectory.appending(component: "file") 267 | 268 | // When 269 | let size = try await subject.fileSizeInBytes(at: path) 270 | 271 | // Then 272 | XCTAssertNil(size) 273 | } 274 | } 275 | 276 | func test_replace_replaces_when_replacingPathIsADirectory_and_targetDirectoryIsAbsent() async throws { 277 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 278 | // Given 279 | let replacedPath = temporaryDirectory.appending(component: "replaced") 280 | let replacingPath = temporaryDirectory.appending(component: "replacing") 281 | try await subject.makeDirectory(at: replacingPath) 282 | let replacingFilePath = replacingPath.appending(component: "file") 283 | try await subject.touch(replacingFilePath) 284 | 285 | // When 286 | try await subject.replace(replacedPath, with: replacingPath) 287 | 288 | // Then 289 | let exists = try await subject.exists(replacedPath.appending(component: "file")) 290 | XCTAssertTrue(exists) 291 | } 292 | } 293 | 294 | func test_replace_replaces_when_replacingPathIsADirectory_and_targetDirectoryIsPresent() async throws { 295 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 296 | // Given 297 | let replacedPath = temporaryDirectory.appending(component: "replaced") 298 | let replacingPath = temporaryDirectory.appending(component: "replacing") 299 | try await subject.makeDirectory(at: replacedPath) 300 | try await subject.makeDirectory(at: replacingPath) 301 | let replacingFilePath = replacingPath.appending(component: "file") 302 | try await subject.touch(replacingFilePath) 303 | 304 | // When 305 | try await subject.replace(replacedPath, with: replacingPath) 306 | 307 | // Then 308 | let exists = try await subject.exists(replacedPath.appending(component: "file")) 309 | XCTAssertTrue(exists) 310 | } 311 | } 312 | 313 | func test_replace_replaces_when_replacingPathDoesntExist() async throws { 314 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 315 | // Given 316 | let replacedPath = temporaryDirectory.appending(component: "replaced") 317 | let replacingPath = temporaryDirectory.appending(component: "replacing") 318 | try await subject.makeDirectory(at: replacedPath) 319 | try await subject.makeDirectory(at: replacingPath) 320 | let replacingFilePath = replacingPath.appending(component: "file") 321 | let replacedFilePath = replacedPath.appending(component: "file") 322 | 323 | // When 324 | var _error: FileSystemError? 325 | do { 326 | try await subject.replace(replacedFilePath, with: replacingFilePath) 327 | } catch { 328 | _error = error as? FileSystemError 329 | } 330 | 331 | // Then 332 | XCTAssertEqual( 333 | _error, 334 | FileSystemError.replacingItemAbsent(replacingPath: replacingFilePath, replacedPath: replacedFilePath) 335 | ) 336 | } 337 | } 338 | 339 | func test_replace_createsTheReplacedPathParentDirectoryIfAbsent() async throws { 340 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 341 | // Given 342 | let replacedPath = temporaryDirectory.appending(component: "replaced") 343 | let replacingPath = temporaryDirectory.appending(component: "replacing") 344 | try await subject.makeDirectory(at: replacingPath) 345 | let replacingFilePath = replacingPath.appending(component: "file") 346 | let replacedFilePath = replacedPath.appending(component: "file") 347 | try await subject.touch(replacingFilePath) 348 | 349 | // When 350 | try await subject.replace(replacedFilePath, with: replacingFilePath) 351 | 352 | // Then 353 | let exists = try await subject.exists(replacedFilePath) 354 | XCTAssertTrue(exists) 355 | } 356 | } 357 | 358 | func test_copy_copiesASourceItemToATargetPath() async throws { 359 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 360 | // Given 361 | let fromPath = temporaryDirectory.appending(component: "from") 362 | let toPath = temporaryDirectory.appending(component: "to") 363 | try await subject.touch(fromPath) 364 | 365 | // When 366 | try await subject.copy(fromPath, to: toPath) 367 | 368 | // Then 369 | let exists = try await subject.exists(toPath) 370 | XCTAssertTrue(exists) 371 | } 372 | } 373 | 374 | func test_copy_createsTargetParentDirectoriesIfNeeded() async throws { 375 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 376 | // Given 377 | let fromPath = temporaryDirectory.appending(component: "from") 378 | let toPath = temporaryDirectory.appending(components: ["directory", "to"]) 379 | try await subject.touch(fromPath) 380 | 381 | // When 382 | try await subject.copy(fromPath, to: toPath) 383 | 384 | // Then 385 | let exists = try await subject.exists(toPath) 386 | XCTAssertTrue(exists) 387 | } 388 | } 389 | 390 | func test_copy_errorsIfTheSourceItemDoesntExist() async throws { 391 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 392 | // Given 393 | let fromPath = temporaryDirectory.appending(component: "from") 394 | let toPath = temporaryDirectory.appending(component: "to") 395 | 396 | // When 397 | var _error: FileSystemError? 398 | do { 399 | try await subject.copy(fromPath, to: toPath) 400 | } catch { 401 | _error = error as? FileSystemError 402 | } 403 | 404 | // Then 405 | XCTAssertEqual(_error, FileSystemError.copiedItemAbsent(copiedPath: fromPath, intoPath: toPath)) 406 | } 407 | } 408 | 409 | func test_locateTraversingUp_whenAnItemIsFound() async throws { 410 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 411 | // Given 412 | let fileToLookUp = temporaryDirectory.appending(component: "FileSystem.swift") 413 | try await self.subject.touch(fileToLookUp) 414 | let veryNestedDirectory = temporaryDirectory.appending(components: ["first", "second", "third"]) 415 | 416 | // When 417 | let got = try await subject.locateTraversingUp( 418 | from: veryNestedDirectory, 419 | relativePath: try RelativePath(validating: "FileSystem.swift") 420 | ) 421 | 422 | // Then 423 | XCTAssertEqual(got, fileToLookUp) 424 | } 425 | } 426 | 427 | func test_locateTraversingUp_whenAnItemIsNotFound() async throws { 428 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 429 | // Given 430 | let veryNestedDirectory = temporaryDirectory.appending(components: ["first", "second", "third"]) 431 | 432 | // When 433 | let got = try await subject.locateTraversingUp( 434 | from: veryNestedDirectory, 435 | relativePath: try RelativePath(validating: "FileSystem.swift") 436 | ) 437 | 438 | // Then 439 | XCTAssertNil(got) 440 | } 441 | } 442 | 443 | func test_createSymbolicLink() async throws { 444 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 445 | // Given 446 | let filePath = temporaryDirectory.appending(component: "file") 447 | let symbolicLinkPath = temporaryDirectory.appending(component: "symbolic") 448 | try await subject.touch(filePath) 449 | 450 | // When 451 | try await subject.createSymbolicLink(from: symbolicLinkPath, to: filePath) 452 | let got = try await subject.resolveSymbolicLink(symbolicLinkPath) 453 | 454 | // Then 455 | XCTAssertEqual(got, filePath) 456 | } 457 | } 458 | 459 | func test_createSymbolicLink_whenTheSymbolicLinkDoesntExist() async throws { 460 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 461 | // Given 462 | let filePath = temporaryDirectory.appending(component: "file") 463 | let symbolicLinkPath = temporaryDirectory.appending(component: "symbolic") 464 | try await subject.touch(filePath) 465 | 466 | // When 467 | var _error: FileSystemError? 468 | do { 469 | _ = try await subject.resolveSymbolicLink(symbolicLinkPath) 470 | } catch { 471 | _error = error as? FileSystemError 472 | } 473 | 474 | // Then 475 | XCTAssertEqual(_error, FileSystemError.absentSymbolicLink(symbolicLinkPath)) 476 | } 477 | } 478 | 479 | func test_resolveSymbolicLink_whenTheDestinationIsRelative() async throws { 480 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 481 | // Given 482 | let symbolicPath = temporaryDirectory.appending(component: "symbolic") 483 | let destinationPath = temporaryDirectory.appending(component: "destination") 484 | try await subject.touch(destinationPath) 485 | try await subject.createSymbolicLink(from: symbolicPath, to: RelativePath(validating: "destination")) 486 | 487 | // When 488 | let got = try await subject.resolveSymbolicLink(symbolicPath) 489 | 490 | // Then 491 | XCTAssertEqual(got, destinationPath) 492 | } 493 | } 494 | 495 | func test_resolveSymbolicLink_whenThePathIsNotASymbolicLink() async throws { 496 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 497 | // Given 498 | let directoryPath = temporaryDirectory.appending(component: "symbolic") 499 | try await subject.makeDirectory(at: directoryPath) 500 | 501 | // When 502 | let got = try await subject.resolveSymbolicLink(directoryPath) 503 | 504 | // Then 505 | XCTAssertEqual(got, directoryPath) 506 | } 507 | } 508 | 509 | func test_zipping() async throws { 510 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 511 | // Given 512 | let filePath = temporaryDirectory.appending(component: "file") 513 | let zipPath = temporaryDirectory.appending(component: "file.zip") 514 | let unzippedPath = temporaryDirectory.appending(component: "unzipped") 515 | try await subject.makeDirectory(at: unzippedPath) 516 | try await subject.touch(filePath) 517 | 518 | // When 519 | try await subject.zipFileOrDirectoryContent(at: filePath, to: zipPath) 520 | try await subject.unzip(zipPath, to: unzippedPath) 521 | 522 | // Then 523 | let exists = try await subject.exists(unzippedPath.appending(component: "file")) 524 | XCTAssertTrue(exists) 525 | } 526 | } 527 | 528 | func test_glob_component_wildcard() async throws { 529 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 530 | // Given 531 | let firstDirectory = temporaryDirectory.appending(component: "first") 532 | let firstSourceFile = firstDirectory.appending(component: "first.swift") 533 | 534 | try await subject.makeDirectory(at: firstDirectory) 535 | try await subject.touch(firstSourceFile) 536 | 537 | // When 538 | let got = try await subject.glob( 539 | directory: temporaryDirectory, 540 | include: ["first/*.swift"] 541 | ) 542 | .collect() 543 | .sorted() 544 | 545 | // Then 546 | XCTAssertEqual(got, [firstSourceFile]) 547 | } 548 | } 549 | 550 | func test_glob_nested_component_wildcard() async throws { 551 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 552 | // Given 553 | let firstSourceFile = temporaryDirectory.appending(component: "first.swift") 554 | 555 | try await subject.touch(firstSourceFile) 556 | 557 | // When 558 | let got = try await subject.glob( 559 | directory: temporaryDirectory, 560 | include: ["*.swift"] 561 | ) 562 | .collect() 563 | .sorted() 564 | 565 | // Then 566 | XCTAssertEqual(got, [firstSourceFile]) 567 | } 568 | } 569 | 570 | // The following behavior works correctly only on Apple environments due to discrepancies in the `Foundation` implementation. 571 | #if !os(Linux) 572 | func test_glob_when_recursive_glob_with_file_being_in_the_base_directory() async throws { 573 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 574 | // Given 575 | let temporaryDirectory = try AbsolutePath(validating: temporaryDirectory.pathString.replacingOccurrences( 576 | of: "/private", 577 | with: "" 578 | )) 579 | let firstSourceFile = temporaryDirectory.appending(component: "first.swift") 580 | 581 | try await subject.touch(firstSourceFile) 582 | 583 | // When 584 | let got = try await subject.glob( 585 | directory: temporaryDirectory, 586 | include: ["*.swift"] 587 | ) 588 | .collect() 589 | .sorted() 590 | 591 | // Then 592 | XCTAssertEqual(got, [firstSourceFile]) 593 | } 594 | } 595 | #endif 596 | 597 | func test_glob_with_nested_directories() async throws { 598 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 599 | // Given 600 | let topFile = temporaryDirectory.appending(component: "top.swift") 601 | let firstDirectory = temporaryDirectory.appending(component: "first") 602 | let firstSourceFile = firstDirectory.appending(component: "first.swift") 603 | let secondDirectory = firstDirectory.appending(component: "second") 604 | let secondSourceFile = firstDirectory.appending(component: "second.swift") 605 | 606 | try await subject.touch(topFile) 607 | try await subject.makeDirectory(at: secondDirectory) 608 | try await subject.touch(firstSourceFile) 609 | try await subject.touch(secondSourceFile) 610 | 611 | // When 612 | let got = try await subject.glob( 613 | directory: temporaryDirectory, 614 | include: ["**/*.swift"] 615 | ) 616 | .collect() 617 | .sorted() 618 | 619 | // Then 620 | XCTAssertEqual(got, [firstSourceFile, secondSourceFile, topFile]) 621 | } 622 | } 623 | 624 | func test_glob_with_file_in_a_nested_directory_with_a_component_wildcard() async throws { 625 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 626 | // Given 627 | let firstDirectory = temporaryDirectory.appending(component: "first") 628 | let firstSourceFile = firstDirectory.appending(component: "first.swift") 629 | 630 | try await subject.makeDirectory(at: firstDirectory) 631 | try await subject.touch(firstSourceFile) 632 | 633 | // When 634 | let got = try await subject.glob( 635 | directory: temporaryDirectory, 636 | include: ["*/*.swift"] 637 | ) 638 | .collect() 639 | .sorted() 640 | 641 | // Then 642 | XCTAssertEqual(got, [firstSourceFile]) 643 | } 644 | } 645 | 646 | func test_glob_with_file_and_only_a_directory_wildcard() async throws { 647 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 648 | // Given 649 | let firstSourceFile = temporaryDirectory.appending(component: "first.swift") 650 | try await subject.touch(firstSourceFile) 651 | 652 | // When 653 | let got = try await subject.glob( 654 | directory: temporaryDirectory, 655 | include: ["**"] 656 | ) 657 | .collect() 658 | .sorted() 659 | 660 | // Then 661 | XCTAssertEqual(got, [firstSourceFile]) 662 | } 663 | } 664 | 665 | func test_glob_with_file_with_a_space_and_only_a_directory_wildcard() async throws { 666 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 667 | // Given 668 | let sourceFile = temporaryDirectory.appending(component: "first plus.swift") 669 | try await subject.touch(sourceFile) 670 | 671 | // When 672 | let got = try await subject.glob( 673 | directory: temporaryDirectory, 674 | include: ["**"] 675 | ) 676 | .collect() 677 | .sorted() 678 | 679 | // Then 680 | XCTAssertEqual(got, [sourceFile]) 681 | } 682 | } 683 | 684 | func test_glob_with_file_with_a_special_character_and_only_a_directory_wildcard() async throws { 685 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 686 | // Given 687 | let sourceFile = temporaryDirectory.appending(component: "firstµplus.swift") 688 | try await subject.touch(sourceFile) 689 | 690 | // When 691 | let got = try await subject.glob( 692 | directory: temporaryDirectory, 693 | include: ["**"] 694 | ) 695 | .collect() 696 | .sorted() 697 | 698 | // Then 699 | XCTAssertEqual(got, [sourceFile]) 700 | } 701 | } 702 | 703 | func test_glob_with_path_wildcard_and_a_constant_file_name() async throws { 704 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 705 | // Given 706 | let directory = temporaryDirectory.appending(component: "first") 707 | let sourceFile = directory.appending(component: "first.swift") 708 | 709 | try await subject.makeDirectory(at: directory) 710 | try await subject.touch(sourceFile) 711 | 712 | // When 713 | let got = try await subject.glob( 714 | directory: temporaryDirectory, 715 | include: ["**/first.swift"] 716 | ) 717 | .collect() 718 | .sorted() 719 | 720 | // Then 721 | XCTAssertEqual(got, [sourceFile]) 722 | } 723 | } 724 | 725 | func test_glob_with_file_in_a_directory_with_a_space() async throws { 726 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 727 | // Given 728 | let directory = temporaryDirectory.appending(component: "directory with a space") 729 | let sourceFile = directory.appending(component: "first.swift") 730 | try await subject.makeDirectory(at: directory) 731 | try await subject.touch(sourceFile) 732 | 733 | // When 734 | let got = try await subject.glob( 735 | directory: directory, 736 | include: ["*.swift"] 737 | ) 738 | .collect() 739 | .sorted() 740 | 741 | // Then 742 | XCTAssertEqual(got, [sourceFile]) 743 | } 744 | } 745 | 746 | func test_glob_with_file_extension_wildcard() async throws { 747 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 748 | // Given 749 | let directory = temporaryDirectory.appending(component: "directory") 750 | let sourceFile = directory.appending(component: "first.swift") 751 | try await subject.makeDirectory(at: directory) 752 | try await subject.touch(sourceFile) 753 | 754 | // When 755 | let got = try await subject.glob( 756 | directory: directory, 757 | include: ["first.*"] 758 | ) 759 | .collect() 760 | .sorted() 761 | 762 | // Then 763 | XCTAssertEqual(got, [sourceFile]) 764 | } 765 | } 766 | 767 | func test_glob_with_hidden_file_and_extension_wildcard() async throws { 768 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 769 | // Given 770 | let directory = temporaryDirectory.appending(component: "directory") 771 | let sourceFile = directory.appending(component: ".hidden.swift") 772 | try await subject.makeDirectory(at: directory) 773 | try await subject.touch(sourceFile) 774 | 775 | // When 776 | let got = try await subject.glob( 777 | directory: directory, 778 | include: [".*.swift"] 779 | ) 780 | .collect() 781 | .sorted() 782 | 783 | // Then 784 | XCTAssertEqual(got, [sourceFile]) 785 | } 786 | } 787 | 788 | func test_glob_with_constant_file() async throws { 789 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 790 | // Given 791 | let directory = temporaryDirectory.appending(component: "directory") 792 | let sourceFile = directory.appending(component: "first.swift") 793 | try await subject.makeDirectory(at: directory) 794 | try await subject.touch(sourceFile) 795 | 796 | // When 797 | let got = try await subject.glob( 798 | directory: directory, 799 | include: ["first.swift"] 800 | ) 801 | .collect() 802 | .sorted() 803 | 804 | // Then 805 | XCTAssertEqual(got, [sourceFile]) 806 | } 807 | } 808 | 809 | func test_glob_with_path_wildcard() async throws { 810 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 811 | // Given 812 | let directory = temporaryDirectory.appending(component: "directory") 813 | let sourceFile = directory.appending(component: "first.swift") 814 | try await subject.makeDirectory(at: directory) 815 | try await subject.touch(sourceFile) 816 | 817 | // When 818 | let got = try await subject.glob( 819 | directory: directory, 820 | include: ["**/first.swift"] 821 | ) 822 | .collect() 823 | .sorted() 824 | 825 | // Then 826 | XCTAssertEqual(got, [sourceFile]) 827 | } 828 | } 829 | 830 | func test_glob_with_nested_files_and_only_a_directory_wildcard() async throws { 831 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 832 | // Given 833 | let firstDirectory = temporaryDirectory.appending(component: "first") 834 | let secondDirectory = firstDirectory.appending(component: "second") 835 | let sourceFile = firstDirectory.appending(component: "file.swift") 836 | try await subject.makeDirectory(at: secondDirectory) 837 | try await subject.touch(sourceFile) 838 | 839 | // When 840 | let got = try await subject.glob( 841 | directory: temporaryDirectory, 842 | include: ["**"] 843 | ) 844 | .collect() 845 | .sorted() 846 | 847 | // Then 848 | XCTAssertEqual(got, [firstDirectory, sourceFile, secondDirectory]) 849 | } 850 | } 851 | 852 | func test_glob_with_nested_files_and_only_a_directory_wildcard_when_ds_store_is_present() async throws { 853 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 854 | // Given 855 | let firstDirectory = temporaryDirectory.appending(component: "first") 856 | let secondDirectory = firstDirectory.appending(component: "second") 857 | let sourceFile = firstDirectory.appending(component: "file.swift") 858 | try await subject.makeDirectory(at: secondDirectory) 859 | try await subject.touch(sourceFile) 860 | try await subject.touch(firstDirectory.appending(component: ".DS_Store")) 861 | try await subject.touch(secondDirectory.appending(component: ".DS_Store")) 862 | 863 | // When 864 | let got = try await subject.glob( 865 | directory: temporaryDirectory, 866 | include: ["**"] 867 | ) 868 | .collect() 869 | .sorted() 870 | 871 | // Then 872 | XCTAssertEqual(got, [firstDirectory, sourceFile, secondDirectory]) 873 | } 874 | } 875 | 876 | func test_glob_with_nested_files_and_only_a_directory_wildcard_when_git_keep_is_present() async throws { 877 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 878 | // Given 879 | let firstDirectory = temporaryDirectory.appending(component: "first") 880 | let secondDirectory = firstDirectory.appending(component: "second") 881 | let sourceFile = firstDirectory.appending(component: "file.swift") 882 | try await subject.makeDirectory(at: secondDirectory) 883 | try await subject.touch(sourceFile) 884 | try await subject.touch(firstDirectory.appending(component: ".gitkeep")) 885 | try await subject.touch(secondDirectory.appending(component: ".gitkeep")) 886 | 887 | // When 888 | let got = try await subject.glob( 889 | directory: temporaryDirectory, 890 | include: ["**"] 891 | ) 892 | .collect() 893 | .sorted() 894 | 895 | // Then 896 | XCTAssertEqual(got, [firstDirectory, sourceFile, secondDirectory]) 897 | } 898 | } 899 | 900 | func test_glob_with_symlink_and_only_a_directory_wildcard() async throws { 901 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 902 | // Given 903 | let firstDirectory = temporaryDirectory.appending(component: "first") 904 | let symlink = firstDirectory.appending(component: "symlink") 905 | let secondDirectory = temporaryDirectory.appending(component: "second") 906 | let sourceFile = secondDirectory.appending(component: "file.swift") 907 | let symlinkSourceFilePath = symlink.appending(component: "file.swift") 908 | try await subject.makeDirectory(at: firstDirectory) 909 | try await subject.makeDirectory(at: secondDirectory) 910 | try await subject.touch(sourceFile) 911 | try await subject.createSymbolicLink(from: symlink, to: secondDirectory) 912 | 913 | // When 914 | let got = try await subject.glob( 915 | directory: firstDirectory, 916 | include: ["**/*.swift"] 917 | ) 918 | .collect() 919 | .sorted() 920 | 921 | // Then 922 | XCTAssertEqual(got, [symlinkSourceFilePath]) 923 | } 924 | } 925 | 926 | func test_glob_with_symlink_as_base_url() async throws { 927 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 928 | // Given 929 | let symlink = temporaryDirectory.appending(component: "symlink") 930 | let firstDirectory = temporaryDirectory.appending(component: "first") 931 | let sourceFile = firstDirectory.appending(component: "file.swift") 932 | let symlinkSourceFilePath = symlink.appending(component: "file.swift") 933 | try await subject.makeDirectory(at: firstDirectory) 934 | try await subject.touch(sourceFile) 935 | try await subject.createSymbolicLink(from: symlink, to: firstDirectory) 936 | 937 | // When 938 | let got = try await subject.glob( 939 | directory: symlink, 940 | include: ["*.swift"] 941 | ) 942 | .collect() 943 | .sorted() 944 | 945 | // Then 946 | XCTAssertEqual(got, [symlinkSourceFilePath]) 947 | } 948 | } 949 | 950 | func test_glob_with_relative_symlink() async throws { 951 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 952 | // Given 953 | let frameworkDir = temporaryDirectory.appending(component: "Framework") 954 | let sourceDir = frameworkDir.appending(component: "Source") 955 | let spmResourcesDir = sourceDir.appending(component: "SwiftPackageResources") 956 | let modelSymLinkPath = spmResourcesDir.appending(component: "MyModel.xcdatamodeld") 957 | 958 | let actualResourcesDir = frameworkDir.appending(component: "Resources") 959 | let actualModelPath = actualResourcesDir.appending(component: "MyModel.xcdatamodeld") 960 | let versionPath = actualModelPath.appending(component: "MyModel_0.xcdatamodel") 961 | 962 | try await subject.makeDirectory(at: spmResourcesDir) 963 | try await subject.makeDirectory(at: actualResourcesDir) 964 | try await subject.makeDirectory(at: actualModelPath) 965 | try await subject.touch(versionPath) 966 | 967 | let relativeActualModelPath = try RelativePath(validating: "../../Resources/MyModel.xcdatamodeld") 968 | try await subject.createSymbolicLink(from: modelSymLinkPath, to: relativeActualModelPath) 969 | 970 | // When 971 | let got = try await subject.glob( 972 | directory: modelSymLinkPath, 973 | include: ["*.xcdatamodel"] 974 | ) 975 | .collect() 976 | .sorted() 977 | 978 | // Then 979 | XCTAssertEqual(got.count, 1) 980 | XCTAssertEqual(got.map(\.basename), [versionPath.basename]) 981 | } 982 | } 983 | 984 | func test_glob_with_relative_directory_symlink() async throws { 985 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 986 | // Given 987 | let frameworkDir = temporaryDirectory.appending(component: "MyFramework") 988 | let testsDir = temporaryDirectory.appending(component: "Tests") 989 | let customSQLiteDir = testsDir.appending(component: "CustomSQLite") 990 | 991 | let myStructPath = frameworkDir.appending(component: "MyStruct.swift") 992 | 993 | try await subject.makeDirectory(at: frameworkDir) 994 | try await subject.makeDirectory(at: customSQLiteDir) 995 | try await subject.touch(myStructPath) 996 | 997 | let rootDirSymLinkPath = customSQLiteDir.appending(component: "MyFramework") 998 | let relativeRootDirPath = try RelativePath(validating: "../..") 999 | try await subject.createSymbolicLink(from: rootDirSymLinkPath, to: relativeRootDirPath) 1000 | 1001 | // When 1002 | let got = try await subject.glob( 1003 | directory: temporaryDirectory, 1004 | include: ["**/*.swift"] 1005 | ) 1006 | .collect() 1007 | .sorted() 1008 | 1009 | // Then 1010 | XCTAssertEqual(got.count, 1) 1011 | XCTAssertEqual(got.map(\.basename), [myStructPath.basename]) 1012 | } 1013 | } 1014 | 1015 | func test_glob_with_double_directory_wildcard() async throws { 1016 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 1017 | // Given 1018 | let firstDirectory = temporaryDirectory.appending(component: "first") 1019 | let firstSourceFile = firstDirectory.appending(component: "first.swift") 1020 | let secondDirectory = firstDirectory.appending(component: "second") 1021 | let secondSourceFile = secondDirectory.appending(component: "second.swift") 1022 | let thirdDirectory = secondDirectory.appending(component: "third") 1023 | let thirdSourceFile = thirdDirectory.appending(component: "third.swift") 1024 | let fourthDirectory = thirdDirectory.appending(component: "fourth") 1025 | let fourthSourceFile = fourthDirectory.appending(component: "fourth.swift") 1026 | 1027 | try await subject.makeDirectory(at: fourthDirectory) 1028 | try await subject.touch(firstSourceFile) 1029 | try await subject.touch(secondSourceFile) 1030 | try await subject.touch(thirdSourceFile) 1031 | try await subject.touch(fourthSourceFile) 1032 | 1033 | // When 1034 | let got = try await subject.glob( 1035 | directory: temporaryDirectory, 1036 | include: ["first/**/third/**/*.swift"] 1037 | ) 1038 | .collect() 1039 | .sorted() 1040 | 1041 | // Then 1042 | XCTAssertEqual(got, [fourthSourceFile, thirdSourceFile]) 1043 | } 1044 | } 1045 | 1046 | func test_glob_with_extension_group() async throws { 1047 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 1048 | // Given 1049 | let swiftSourceFile = temporaryDirectory.appending(component: "file.swift") 1050 | let cppSourceFile = temporaryDirectory.appending(component: "file.cpp") 1051 | let jsSourceFile = temporaryDirectory.appending(component: "file.js") 1052 | try await subject.touch(swiftSourceFile) 1053 | try await subject.touch(cppSourceFile) 1054 | try await subject.touch(jsSourceFile) 1055 | 1056 | // When 1057 | let got = try await subject.glob(directory: temporaryDirectory, include: ["*.{swift,cpp}"]) 1058 | .collect() 1059 | .sorted() 1060 | 1061 | // Then 1062 | XCTAssertEqual( 1063 | got, 1064 | [ 1065 | cppSourceFile, 1066 | swiftSourceFile, 1067 | ] 1068 | ) 1069 | } 1070 | } 1071 | 1072 | func test_remove_file() async throws { 1073 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 1074 | // Given 1075 | let file = temporaryDirectory.appending(component: "test") 1076 | try await subject.touch(file) 1077 | 1078 | // When 1079 | try await subject.remove(file) 1080 | 1081 | // Then 1082 | let exists = try await subject.exists(file) 1083 | XCTAssertFalse(exists) 1084 | } 1085 | } 1086 | 1087 | func test_remove_non_existing_file() async throws { 1088 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 1089 | // Given 1090 | let file = temporaryDirectory.appending(component: "test") 1091 | 1092 | // When / Then 1093 | try await subject.remove(file) 1094 | } 1095 | } 1096 | 1097 | func test_remove_directory_with_files() async throws { 1098 | try await subject.runInTemporaryDirectory(prefix: "FileSystem") { temporaryDirectory in 1099 | // Given 1100 | let directory = temporaryDirectory.appending(component: "directory") 1101 | let nestedDirectory = directory.appending(component: "nested") 1102 | let file = nestedDirectory.appending(component: "test") 1103 | try await subject.makeDirectory(at: nestedDirectory) 1104 | try await subject.touch(file) 1105 | 1106 | // When 1107 | try await subject.remove(directory) 1108 | 1109 | // Then 1110 | let directoryExists = try await subject.exists(directory) 1111 | let nestedDirectoryExists = try await subject.exists(nestedDirectory) 1112 | let fileExists = try await subject.exists(file) 1113 | XCTAssertFalse(directoryExists) 1114 | XCTAssertFalse(nestedDirectoryExists) 1115 | XCTAssertFalse(fileExists) 1116 | } 1117 | } 1118 | } 1119 | -------------------------------------------------------------------------------- /Tests/GlobTests/PatternTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Glob 4 | 5 | final class PatternTests: XCTestCase { 6 | func test_pathWildcard_matchesSingleNestedFolders() throws { 7 | try XCTAssertMatches("Target/AutoMockable.generated.swift", pattern: "**/*.generated.swift") 8 | } 9 | 10 | func test_pathWildcard_with_constant_component() throws { 11 | try XCTAssertMatches("file.swift", pattern: "**/file.swift") 12 | } 13 | 14 | func test_pathWildcard_matchesDirectFile() throws { 15 | try XCTAssertMatches("AutoMockable.generated.swift", pattern: "**/*.generated.swift") 16 | } 17 | 18 | func test_pathWildcard_does_not_match() throws { 19 | try XCTAssertDoesNotMatch("AutoMockable.non-generated.swift", pattern: "**/*.generated.swift") 20 | } 21 | 22 | func test_double_pathWildcard_matchesDirectFileInNestedDirectory() throws { 23 | try XCTAssertMatches("Target/Pivot/AutoMockable.generated.swift", pattern: "**/Pivot/**/*.generated.swift") 24 | } 25 | 26 | func test_double_pathWildcard_does_not_match_when_pivot_does_not_match() throws { 27 | try XCTAssertDoesNotMatch( 28 | "Target/NonMatchingPivot/AutoMockable.generated.swift", 29 | pattern: "**/Pivot/**/*.generated.swift" 30 | ) 31 | } 32 | 33 | func test_double_pathWildcard_with_prefix_constants_matchesDirectFileInNestedDirectory() throws { 34 | try XCTAssertMatches("Target/Extra/Pivot/AutoMockable.generated.swift", pattern: "Target/**/Pivot/**/*.generated.swift") 35 | } 36 | 37 | func test_pathWildcard_matchesMultipleNestedFolders() throws { 38 | try XCTAssertMatches("Target/Generated/AutoMockable.generated.swift", pattern: "**/*.generated.swift") 39 | } 40 | 41 | func test_componentWildcard_matchesNonNestedFiles() throws { 42 | try XCTAssertMatches("AutoMockable.generated.swift", pattern: "*.generated.swift") 43 | } 44 | 45 | func test_componentWildcard_doesNotMatchNestedPaths() throws { 46 | try XCTAssertDoesNotMatch("Target/AutoMockable.generated.swift", pattern: "*.generated.swift") 47 | } 48 | 49 | func test_multipleWildcards_matchesWithMultipleConstants() throws { 50 | // this can be tricky for some implementations because as they are parsing the first wildcard, 51 | // it will see a match and move on and the remaining pattern and content will not match 52 | try XCTAssertMatches("Target/AutoMockable/Sources/AutoMockable.generated.swift", pattern: "**/AutoMockable*.swift") 53 | } 54 | 55 | func test_matchingLongStrings_onSecondaryThread_doesNotCrash() async throws { 56 | // In Debug when using async methods, long strings would cause crashes with recursion for strings approaching ~90 57 | // characters. 58 | // Test that our implementation can handle long strings in async cases. 59 | try await Task { 60 | try await runStressTest() 61 | }.value 62 | } 63 | 64 | func runStressTest() async throws { 65 | try XCTAssertMatches( 66 | "base/Shared/Tests/Objects/Utilities/PathsMoreAbitraryStringLengthSomeVeryLongTypeNameThat+SomeLongExtensionNameTests.swift", 67 | pattern: "base/**/Tests/**/*Tests.swift" 68 | ) 69 | } 70 | 71 | func test_pathWildcard_pathComponentsOnly_doesNotMatchPath() throws { 72 | var options = Pattern.Options.default 73 | options.supportsPathLevelWildcards = false 74 | try XCTAssertDoesNotMatch("Target/Other/.build", pattern: "**/.build", options: options) 75 | } 76 | 77 | func test_componentWildcard_pathComponentsOnly_doesMatchSingleComponent() throws { 78 | var options = Pattern.Options.default 79 | options.supportsPathLevelWildcards = false 80 | try XCTAssertMatches("Target/.build", pattern: "*/.build", options: options) 81 | } 82 | 83 | func test_constant() throws { 84 | try XCTAssertMatches("abc", pattern: "abc") 85 | } 86 | 87 | func test_ranges() throws { 88 | try XCTAssertMatches("b", pattern: "[a-c]") 89 | try XCTAssertMatches("B", pattern: "[A-C]") 90 | try XCTAssertDoesNotMatch("n", pattern: "[a-c]") 91 | } 92 | 93 | func test_multipleRanges() throws { 94 | try XCTAssertMatches("b", pattern: "[a-cA-C]") 95 | try XCTAssertMatches("B", pattern: "[a-cA-C]") 96 | try XCTAssertDoesNotMatch("n", pattern: "[a-cA-C]") 97 | try XCTAssertDoesNotMatch("N", pattern: "[a-cA-C]") 98 | try XCTAssertDoesNotMatch("n", pattern: "[a-cA-Z]") 99 | try XCTAssertMatches("N", pattern: "[a-cA-Z]") 100 | } 101 | 102 | func test_negateRange() throws { 103 | try XCTAssertDoesNotMatch("abc", pattern: "ab[^c]", options: .go) 104 | } 105 | 106 | func test_singleCharacter_doesNotMatchSeparator() throws { 107 | try XCTAssertDoesNotMatch("a/b", pattern: "a?b") 108 | } 109 | 110 | func test_namedCharacterClasses_alpha() throws { 111 | try XCTAssertMatches("b", pattern: "[[:alpha:]]") 112 | try XCTAssertMatches("B", pattern: "[[:alpha:]]") 113 | try XCTAssertMatches("ē", pattern: "[[:alpha:]]") 114 | try XCTAssertMatches("ž", pattern: "[[:alpha:]]") 115 | try XCTAssertDoesNotMatch("9", pattern: "[[:alpha:]]") 116 | try XCTAssertDoesNotMatch("&", pattern: "[[:alpha:]]") 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /Tests/GlobTests/TestHelpers/XCTAssertMatches.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import Glob 4 | 5 | func XCTAssertMatches( 6 | _ value: String, 7 | pattern: String, 8 | options: Glob.Pattern.Options = .default, 9 | file: StaticString = #filePath, 10 | line: UInt = #line 11 | ) throws { 12 | try XCTAssertTrue( 13 | Pattern(pattern, options: options).match(value), 14 | "\(value) did not match pattern \(pattern) with options \(options)", 15 | file: file, 16 | line: line 17 | ) 18 | } 19 | 20 | func XCTAssertDoesNotMatch( 21 | _ value: String, 22 | pattern: String, 23 | options: Glob.Pattern.Options = .default, 24 | file: StaticString = #filePath, 25 | line: UInt = #line 26 | ) throws { 27 | try XCTAssertFalse( 28 | Pattern(pattern, options: options).match(value), 29 | "'\(value)' matched pattern '\(pattern)' with options \(options)", 30 | file: file, 31 | line: line 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /Tests/GlobTests/TestHelpers/XCTExpectFailure.swift: -------------------------------------------------------------------------------- 1 | // XCTExpectFailure is only available on Apple platforms 2 | // https://github.com/apple/swift-corelibs-xctest/issues/438 3 | #if !os(macOS) && !os(iOS) && !os(watchOS) && !os(tvOS) 4 | func XCTExpectFailure(_: () throws -> Void) rethrows {} 5 | #endif 6 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # git-cliff ~ configuration file 2 | # https://git-cliff.org/docs/configuration 3 | 4 | [remote.github] 5 | owner = "tuist" 6 | repo = "FileSystem" 7 | # token = "" 8 | 9 | [changelog] 10 | # template for the changelog header 11 | header = """ 12 | # Changelog\n 13 | All notable changes to this project will be documented in this file. 14 | 15 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 16 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n 17 | """ 18 | # template for the changelog body 19 | # https://keats.github.io/tera/docs/#introduction 20 | body = """ 21 | {%- macro remote_url() -%} 22 | https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} 23 | {%- endmacro -%} 24 | 25 | {% if version -%} 26 | ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} 27 | {% else -%} 28 | ## [Unreleased] 29 | {% endif -%} 30 | 31 | ### Details\ 32 | 33 | {% for group, commits in commits | group_by(attribute="group") %} 34 | #### {{ group | upper_first }} 35 | {%- for commit in commits %} 36 | - {{ commit.message | upper_first | trim }}\ 37 | {% if commit.github.username %} by @{{ commit.github.username }}{%- endif -%} 38 | {% if commit.github.pr_number %} in \ 39 | [#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}) \ 40 | {%- endif -%} 41 | {% endfor %} 42 | {% endfor %} 43 | 44 | {%- if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %} 45 | ## New Contributors 46 | {%- endif -%} 47 | 48 | {% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %} 49 | * @{{ contributor.username }} made their first contribution 50 | {%- if contributor.pr_number %} in \ 51 | [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \ 52 | {%- endif %} 53 | {%- endfor %}\n 54 | """ 55 | # template for the changelog footer 56 | footer = """ 57 | {%- macro remote_url() -%} 58 | https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} 59 | {%- endmacro -%} 60 | 61 | {% for release in releases -%} 62 | {% if release.version -%} 63 | {% if release.previous.version -%} 64 | [{{ release.version | trim_start_matches(pat="v") }}]: \ 65 | {{ self::remote_url() }}/compare/{{ release.previous.version }}..{{ release.version }} 66 | {% endif -%} 67 | {% else -%} 68 | [unreleased]: {{ self::remote_url() }}/compare/{{ release.previous.version }}..HEAD 69 | {% endif -%} 70 | {% endfor %} 71 | 72 | """ 73 | # remove the leading and trailing whitespace from the templates 74 | trim = true 75 | # postprocessors 76 | postprocessors = [] 77 | 78 | [git] 79 | # parse the commits based on https://www.conventionalcommits.org 80 | conventional_commits = true 81 | # filter out the commits that are not conventional 82 | filter_unconventional = true 83 | # process each line of a commit as an individual commit 84 | split_commits = false 85 | # regex for preprocessing the commit messages 86 | commit_preprocessors = [ 87 | # remove issue numbers from commits 88 | { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "" }, 89 | ] 90 | # protect breaking changes from being skipped due to matching a skipping commit_parser 91 | protect_breaking_commits = false 92 | # filter out the commits that are not matched by commit parsers 93 | filter_commits = false 94 | # regex for matching git tags 95 | tag_pattern = "[0-9].*" 96 | # regex for skipping tags 97 | skip_tags = "beta|alpha" 98 | # regex for ignoring tags 99 | ignore_tags = "rc" 100 | # sort the tags topologically 101 | topo_order = false 102 | # sort the commits inside sections by oldest/newest order 103 | sort_commits = "newest" 104 | 105 | [bump] 106 | breaking_always_bump_major = true 107 | features_always_bump_minor = true 108 | initial_tag = "0.3.0" 109 | -------------------------------------------------------------------------------- /mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | "tuist" = "4.50.2" 3 | "swiftlint" = "0.54.0" 4 | "swiftformat" = "0.52.10" 5 | "git-cliff" = "2.6.0" 6 | 7 | [plugins] 8 | tuist = "https://github.com/asdf-community/asdf-tuist" 9 | swift = "https://github.com/fcrespo82/asdf-swift" 10 | podman = "https://github.com/tvon/asdf-podman.git" 11 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | ":semanticCommits", 5 | "config:recommended", 6 | ":disableDependencyDashboard" 7 | ], 8 | "packageRules": [ 9 | { 10 | "matchUpdateTypes": ["minor", "patch", "pin", "digest"], 11 | "labels": ["changelog:updated-dependencies"], 12 | "automerge": true, 13 | "automergeType": "pr", 14 | "automergeStrategy": "auto" 15 | } 16 | ], 17 | "lockFileMaintenance": { 18 | "enabled": true, 19 | "automerge": true 20 | } 21 | } 22 | --------------------------------------------------------------------------------