diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..1f7a37c --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,40 @@ +{ + "name": "Swift", + "image": "brightdigit/publish-xml:6.4", + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": "false", + "username": "vscode", + "upgradePackages": "false" + }, + "ghcr.io/devcontainers/features/git:1": { + "version": "os-provided", + "ppa": "false" + } + }, + "postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", + "runArgs": [ + "--cap-add=SYS_PTRACE", + "--security-opt", + "seccomp=unconfined" + ], + // Configure tool-specific properties. + "customizations": { + // Configure properties specific to VS Code. + "vscode": { + // Set *default* container specific settings.json values on container create. + "settings": { + "lldb.library": "/usr/lib/liblldb.so" + }, + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "sswg.swift-lang" + ] + } + }, + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Set `remoteUser` to `root` to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. + "remoteUser": "root" +} \ No newline at end of file diff --git a/.github/actions/setup-tools/action.yml b/.github/actions/setup-tools/action.yml new file mode 100644 index 0000000..6e9cd53 --- /dev/null +++ b/.github/actions/setup-tools/action.yml @@ -0,0 +1,26 @@ +name: Setup mise tools +description: >- + Restore (or build and save) the mise tool cache and put the binaries on PATH. + +runs: + using: composite + steps: + - name: Cache mise tools + id: mise-cache + uses: actions/cache@v4 + with: + path: ~/.local/share/mise/installs + key: mise-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.mise.toml') }} + restore-keys: | + mise-v2-${{ runner.os }}-${{ runner.arch }}- + - name: Install mise tools (cache miss) + if: steps.mise-cache.outputs.cache-hit != 'true' + uses: jdx/mise-action@v4 + with: + cache: false + - name: Configure PATH for cached mise tools + if: steps.mise-cache.outputs.cache-hit == 'true' + uses: jdx/mise-action@v4 + with: + install: false + cache: false diff --git a/.github/workflows/Files.yml b/.github/workflows/Files.yml new file mode 100644 index 0000000..f47aa0d --- /dev/null +++ b/.github/workflows/Files.yml @@ -0,0 +1,267 @@ +name: Files + +# Standalone CI for this BrightDigit Swift package on Swift 6.4 (the BrightDigit +# CI template). These five workflows are kept byte-identical except the name above; +# the only per-package difference is externalized to the ENABLE_WASM repo variable. +# Linux + wasm run in the nightly-6.4 container; macOS + the Apple-platform suite +# run on the GitHub-hosted xcode-27 runner (/Applications/Xcode_27.0.app, Xcode 27 / +# Swift 6.4-dev); Windows on a swift.org nightly snapshot; Android via a swift.org SDK +# bundle. The wasm step is gated on ENABLE_WASM — set it to 'false' where a +# dependency can't build for wasm (e.g. Yams). +on: + push: + branches: [main] + tags: ['v*.*.*'] + paths-ignore: + - '**.md' + - 'LICENSE' + pull_request: + paths-ignore: + - '**.md' + - 'LICENSE' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + build-ubuntu: + name: Build on Ubuntu + needs: configure + runs-on: ubuntu-latest + container: swiftlang/swift:nightly-6.4.x-noble + if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} + # Ubuntu variants from a matrix (configure → ubuntu-type): standard ('') always; + # wasm + wasm-embedded when ENABLE_WASM != 'false'. Both wasm variants share the + # same nightly-6.4.x SDK bundle. OpenAPI packages guard URLSession behind + # #if !os(WASI) so the wasm/embedded legs build. + strategy: + fail-fast: false + matrix: + type: ${{ fromJSON(needs.configure.outputs.ubuntu-type) }} + steps: + - uses: actions/checkout@v6 + - uses: brightdigit/swift-build@v1 + id: build + with: + type: ${{ matrix.type }} + # wasm / wasm-embedded are build-only: WasmKit doesn't mount the Foundation + # resource bundles the tests load, and embedded hits OpenAPIRuntime keypath + # limits at runtime. The standard ('') leg runs the full suite + coverage. + build-only: ${{ matrix.type != '' }} + wasm-sdk-url: https://download.swift.org/swift-6.4.x-branch/wasm-sdk/swift-6.4.x-DEVELOPMENT-SNAPSHOT-2026-06-15-a/swift-6.4.x-DEVELOPMENT-SNAPSHOT-2026-06-15-a_wasm.artifactbundle.tar.gz + wasm-sdk-checksum: c014a0162de6eeeef4f07904ce096ccee5405c1478a12c99a3a9750bd42d7702 + wasm-swift-flags: >- + -Xcc -D_WASI_EMULATED_SIGNAL -Xcc -D_WASI_EMULATED_MMAN + -Xlinker -lwasi-emulated-signal -Xlinker -lwasi-emulated-mman + -Xlinker -lwasi-emulated-getpid + -Xlinker --initial-memory=536870912 -Xlinker --max-memory=536870912 + - name: Install curl (required by Codecov uploader) + if: steps.build.outputs.contains-code-coverage == 'true' + run: | + if ! command -v curl &> /dev/null; then + apt-get update -q + apt-get install -y --no-install-recommends curl ca-certificates + fi + # brightdigit fork of swift-coverage-action: upstream @v5 can't pair the + # profdata with the test binary under Swift 6.4's swiftbuild layout + # (.so + -test-runner, no .xctest) — issue #92. Pinned by SHA. + - uses: brightdigit/swift-coverage-action@2f8538f723b99ab2406ac3a0e5b3355a9de4cf6c + if: steps.build.outputs.contains-code-coverage == 'true' + id: coverage-files + with: + search-paths: .build + fail-on-empty-output: true + - name: Upload coverage to Codecov + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v7 + with: + fail_ci_if_error: false + flags: noble,swift-6.4 + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ join(fromJSON(steps.coverage-files.outputs.files), ',') }} + + configure: + name: Configure Matrix + runs-on: ubuntu-latest + outputs: + full-matrix: ${{ steps.check.outputs.full }} + run-windows: ${{ steps.check.outputs.windows }} + ubuntu-type: ${{ steps.check.outputs.ubuntu-type }} + steps: + - uses: actions/checkout@v6 + - id: check + name: Determine matrix scope + run: | + # Three tiers: small set (build-ubuntu[+wasm]/build-macos/lint, always) < + # full-matrix (macOS-platforms/android) < +Windows. full-matrix runs on + # main / semver / dispatch / PRs into main or semver. Windows is the most + # expensive leg, so run-windows is the same MINUS PRs into semver branches. + FULL=false; WIN=false + REF="${{ github.ref }}"; EVENT="${{ github.event_name }}"; BASE_REF="${{ github.base_ref }}" + if [[ "$REF" == "refs/heads/main" ]]; then FULL=true; WIN=true + elif [[ "$REF" =~ ^refs/heads/v?[0-9]+\.[0-9]+\.[0-9]+ ]]; then FULL=true; WIN=true + elif [[ "$EVENT" == "workflow_dispatch" ]]; then FULL=true; WIN=true + elif [[ "$EVENT" == "pull_request" ]]; then + if [[ "$BASE_REF" == "main" ]]; then FULL=true; WIN=true + elif [[ "$BASE_REF" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+ ]]; then FULL=true + fi + fi + echo "full=$FULL" >> "$GITHUB_OUTPUT" + echo "windows=$WIN" >> "$GITHUB_OUTPUT" + # Ubuntu build variants: standard ('') always; wasm + wasm-embedded + # unless the package disables wasm via ENABLE_WASM (e.g. Yams). + if [[ "${{ vars.ENABLE_WASM }}" != "false" ]]; then + echo 'ubuntu-type=["","wasm","wasm-embedded"]' >> "$GITHUB_OUTPUT" + else + echo 'ubuntu-type=[""]' >> "$GITHUB_OUTPUT" + fi + + # macOS on Swift 6.4 via the GitHub-hosted xcode-27 runner + # (/Applications/Xcode_27.0.app; Xcode 27 ships Swift 6.4-dev). + build-macos: + name: Build on macOS + runs-on: xcode-27 + if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} + steps: + - uses: actions/checkout@v6 + - uses: brightdigit/swift-build@v1 + id: build + with: + xcode: "/Applications/Xcode_27.0.app" + - name: Process coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: sersoft-gmbh/swift-coverage-action@v5 + with: + search-paths: .build + - name: Upload coverage to Codecov + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v7 + with: + fail_ci_if_error: false + flags: spm,macos + token: ${{ secrets.CODECOV_TOKEN }} + + # Windows nightly 6.4 via a swift.org development snapshot (compnerd/gha-setup-swift + # under swift-build), on hosted runners. Own tier (run-windows): the most + # expensive leg, so it runs on main / semver / dispatch / PRs into main, but NOT + # PRs into semver branches. Bump the snapshot id periodically (swift.org GCs them). + build-windows: + name: Build on Windows + needs: configure + if: ${{ needs.configure.outputs.run-windows == 'true' }} + runs-on: ${{ matrix.runs-on }} + strategy: + fail-fast: false + matrix: + runs-on: [windows-2022, windows-2025] + swift: + - version: swift-6.4.x-branch + build: 6.4.x-DEVELOPMENT-SNAPSHOT-2026-06-01-a + steps: + - uses: actions/checkout@v6 + - uses: brightdigit/swift-build@v1 + id: build + with: + windows-swift-version: ${{ matrix.swift.version }} + windows-swift-build: ${{ matrix.swift.build }} + + # Full Apple-platform suite (iOS/watchOS/tvOS) on Swift 6.4, on the GitHub-hosted + # xcode-27 runner (/Applications/Xcode_27.0.app, Xcode 27) using the 27.0 simulator + # runtimes (download-platform: true fetches any not preinstalled on the image). + # Blocking leg. Bump deviceName/osVersion as the hosted Xcode image moves. + # The watchOS leg is gated on the ENABLE_WATCHOS repo variable (set 'false' to skip it + # where the watchOS-27 SDK rejects deps inferring 8.0 — SwiftPM #10188; see + # brightdigit.com#119). iOS/tvOS always run. + build-macos-platforms: + name: Build on macOS (Platforms) + needs: configure + if: ${{ needs.configure.outputs.full-matrix == 'true' }} + runs-on: xcode-27 + strategy: + fail-fast: false + matrix: + include: + - { type: ios, deviceName: "iPhone 17 Pro", osVersion: "27.0" } + - { type: watchos, deviceName: "Apple Watch Ultra 3 (49mm)", osVersion: "27.0" } + - { type: tvos, deviceName: "Apple TV 4K (3rd generation)", osVersion: "27.0" } + steps: + - uses: actions/checkout@v6 + - name: Build and Test + id: build + # watchOS disabled where ENABLE_WATCHOS=false (SwiftPM #10188, watchOS-27 + # deployment-target clamp); re-enable per brightdigit.com#119. + if: ${{ !(matrix.type == 'watchos' && vars.ENABLE_WATCHOS == 'false') }} + uses: brightdigit/swift-build@v1 + with: + type: ${{ matrix.type }} + xcode: "/Applications/Xcode_27.0.app" + deviceName: ${{ matrix.deviceName }} + osVersion: ${{ matrix.osVersion }} + download-platform: true + - name: Process coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: sersoft-gmbh/swift-coverage-action@v5 + with: + search-paths: .build + - name: Upload coverage to Codecov + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v7 + with: + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} + flags: ${{ format('{0}{1}', matrix.type, matrix.osVersion) }} + + # Android on nightly 6.4 via a swift.org swift-6.4.x-branch snapshot SDK bundle + # (custom-sdk-url path through swift-build → skiptools/swift-android-action). + # Blocking leg; build-only (android-run-tests: false). + # Bump the snapshot id periodically. + build-android: + name: Build on Android + needs: configure + if: ${{ needs.configure.outputs.full-matrix == 'true' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + android-api-level: [34] + steps: + - uses: actions/checkout@v6 + - name: Free disk space + uses: jlumbroso/free-disk-space@v1.3.1 + with: + tool-cache: false + android: false + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: true + - uses: brightdigit/swift-build@v1 + with: + type: android + android-swift-version: 6.4.x-DEVELOPMENT-SNAPSHOT-2026-06-15-a + android-sdk-url: https://download.swift.org/swift-6.4.x-branch/android-sdk/swift-6.4.x-DEVELOPMENT-SNAPSHOT-2026-06-15-a/swift-6.4.x-DEVELOPMENT-SNAPSHOT-2026-06-15-a_android.artifactbundle.tar.gz + android-sdk-id: swift-6.4.x-DEVELOPMENT-SNAPSHOT-2026-06-15-a_android + android-api-level: ${{ matrix.android-api-level }} + android-run-tests: false + + lint: + name: Linting + if: ${{ !cancelled() && !failure() && !contains(github.event.head_commit.message, 'ci skip') }} + needs: [build-ubuntu, build-macos, build-windows, build-macos-platforms] + runs-on: ubuntu-latest + container: swiftlang/swift:nightly-6.4.x-noble + steps: + - name: Install curl (required by setup-tools) + run: | + apt-get update -q + apt-get install -y --no-install-recommends curl ca-certificates + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-tools + - name: Lint + run: ./Scripts/lint.sh + env: + LINT_MODE: STRICT diff --git a/.github/workflows/check-unsafe-flags.yml b/.github/workflows/check-unsafe-flags.yml new file mode 100644 index 0000000..3af5e56 --- /dev/null +++ b/.github/workflows/check-unsafe-flags.yml @@ -0,0 +1,39 @@ +name: Check for unsafeFlags + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + dump-package-check: + name: Dump Swift package (authoritative) and scan JSON + runs-on: ubuntu-latest + container: + image: swiftlang/swift:nightly-6.4.x-noble + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install jq + run: | + apt-get update && apt-get install -y jq + + - name: Dump package JSON and check for unsafeFlags + shell: bash + run: | + set -euo pipefail + # Compute unsafeFlags array directly from the dump (don't store the full dump variable) + unsafe_flags=$(swift package dump-package | jq -c '[.. | objects | .unsafeFlags? // empty]') + # Check array length to decide failure + if [ "$(echo "$unsafe_flags" | jq 'length')" -gt 0 ]; then + echo "ERROR: unsafeFlags found in resolved package JSON:" + echo "$unsafe_flags" | jq '.' || true + echo "--- resolved package dump (first 200 lines) ---" + # Print a sample of the authoritative dump (re-run dump-package for the sample) + swift package dump-package | sed -n '1,200p' || true + exit 1 + else + echo "No unsafeFlags in resolved package JSON." + fi diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 0000000..b5e8cfd --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,44 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" + +jobs: + claude-review: + # Optional: Filter by PR author + # if: | + # github.event.pull_request.user.login == 'external-contributor' || + # github.event.pull_request.user.login == 'new-developer' || + # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' + plugins: 'code-review@claude-code-plugins' + prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 0000000..6b15fac --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,50 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + # claude_args: '--allowed-tools Bash(gh pr *)' + diff --git a/.github/workflows/cleanup-caches.yml b/.github/workflows/cleanup-caches.yml new file mode 100644 index 0000000..f0124e2 --- /dev/null +++ b/.github/workflows/cleanup-caches.yml @@ -0,0 +1,29 @@ +name: Cleanup Branch Caches +on: + delete: + +jobs: + cleanup: + runs-on: ubuntu-latest + permissions: + actions: write + steps: + - name: Cleanup caches for deleted branch + uses: actions/github-script@v9 + with: + script: | + const ref = `refs/heads/${context.payload.ref}`; + const caches = await github.rest.actions.getActionsCacheList({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: ref, + }); + for (const cache of caches.data.actions_caches) { + console.log(`Deleting cache: ${cache.key}`); + await github.rest.actions.deleteActionsCacheById({ + owner: context.repo.owner, + repo: context.repo.repo, + cache_id: cache.id, + }); + } + console.log(`Deleted ${caches.data.actions_caches.length} cache(s) for ${ref}`); diff --git a/.github/workflows/swift-source-compat.yml b/.github/workflows/swift-source-compat.yml new file mode 100644 index 0000000..d4d03bb --- /dev/null +++ b/.github/workflows/swift-source-compat.yml @@ -0,0 +1,28 @@ +name: Swift Source Compatibility + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + swift-source-compat-suite: + name: Test Swift ${{ matrix.container }} For Source Compatibility Suite + runs-on: ubuntu-latest + if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} + + strategy: + fail-fast: false + matrix: + container: + - swiftlang/swift:nightly-6.4.x-noble + + container: ${{ matrix.container }} + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Test Swift 6.x For Source Compatibility + run: swift build --disable-sandbox --verbose --configuration release diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..87bc056 --- /dev/null +++ b/.mise.toml @@ -0,0 +1,16 @@ +[settings] +# Enable experimental features (required for the spm backend) +experimental = true + +[tools] +# Swift development tools (managed via mise) +# 604.x aligns swift-format (and its swift-syntax) with the Swift 6.4 toolchain. +# Only one 604 tag exists so far; bump when a newer 604 prerelease/release lands. +"spm:swiftlang/swift-format" = "604.0.0-prerelease-2025-12-17" +"aqua:realm/SwiftLint" = "0.63.2" +"spm:peripheryapp/periphery" = "3.7.4" + +[tasks] +swift-format = "swift-format" +swiftlint = "swiftlint" +periphery = "periphery" diff --git a/.periphery.yml b/.periphery.yml new file mode 100644 index 0000000..57e9c38 --- /dev/null +++ b/.periphery.yml @@ -0,0 +1,3 @@ +retain_public: true +targets: +- Files diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..834c7ce --- /dev/null +++ b/.spi.yml @@ -0,0 +1,6 @@ +version: 1 +builder: + configs: + - documentation_targets: [Files] + swift_version: 5.9 + custom_documentation_parameters: [--include-extended-types] diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..257f555 --- /dev/null +++ b/.swift-format @@ -0,0 +1,70 @@ +{ + "fileScopedDeclarationPrivacy" : { + "accessLevel" : "fileprivate" + }, + "indentation" : { + "spaces" : 2 + }, + "indentConditionalCompilationBlocks" : true, + "indentSwitchCaseLabels" : false, + "lineBreakAroundMultilineExpressionChainComponents" : false, + "lineBreakBeforeControlFlowKeywords" : false, + "lineBreakBeforeEachArgument" : false, + "lineBreakBeforeEachGenericRequirement" : false, + "lineLength" : 100, + "maximumBlankLines" : 1, + "multiElementCollectionTrailingCommas" : true, + "noAssignmentInExpressions" : { + "allowedFunctions" : [ + "XCTAssertNoThrow" + ] + }, + "prioritizeKeepingFunctionOutputTogether" : false, + "respectsExistingLineBreaks" : true, + "rules" : { + "AllPublicDeclarationsHaveDocumentation" : true, + "AlwaysUseLiteralForEmptyCollectionInit" : false, + "AlwaysUseLowerCamelCase" : true, + "AmbiguousTrailingClosureOverload" : true, + "BeginDocumentationCommentWithOneLineSummary" : false, + "DoNotUseSemicolons" : true, + "DontRepeatTypeInStaticProperties" : true, + "FileScopedDeclarationPrivacy" : false, + "FullyIndirectEnum" : true, + "GroupNumericLiterals" : true, + "IdentifiersMustBeASCII" : true, + "NeverForceUnwrap" : true, + "NeverUseForceTry" : true, + "NeverUseImplicitlyUnwrappedOptionals" : true, + "NoAccessLevelOnExtensionDeclaration" : true, + "NoAssignmentInExpressions" : true, + "NoBlockComments" : true, + "NoCasesWithOnlyFallthrough" : true, + "NoEmptyTrailingClosureParentheses" : true, + "NoLabelsInCasePatterns" : true, + "NoLeadingUnderscores" : true, + "NoParensAroundConditions" : true, + "NoPlaygroundLiterals" : true, + "NoVoidReturnOnFunctionSignature" : true, + "OmitExplicitReturns" : false, + "OneCasePerLine" : true, + "OneVariableDeclarationPerLine" : true, + "OnlyOneTrailingClosureArgument" : true, + "OrderedImports" : true, + "ReplaceForEachWithForLoop" : true, + "ReturnVoidInsteadOfEmptyTuple" : true, + "TypeNamesShouldBeCapitalized" : true, + "UseEarlyExits" : false, + "UseExplicitNilCheckInConditions" : true, + "UseLetInEveryBoundCaseVariable" : true, + "UseShorthandTypeNames" : true, + "UseSingleLinePropertyGetter" : true, + "UseSynthesizedInitializer" : true, + "UseTripleSlashForDocumentationComments" : true, + "UseWhereClausesInForLoops" : true, + "ValidateDocumentationComments" : true + }, + "spacesAroundRangeFormationOperators" : false, + "tabWidth" : 2, + "version" : 1 +} diff --git a/.swift-version b/.swift-version index 819e07a..944c7e2 100644 --- a/.swift-version +++ b/.swift-version @@ -1 +1 @@ -5.0 +6.4.x-snapshot diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..4ccdb85 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,149 @@ +opt_in_rules: + - array_init + - closure_body_length + - closure_end_indentation + - closure_spacing + - collection_alignment + - conditional_returns_on_newline + - contains_over_filter_count + - contains_over_filter_is_empty + - contains_over_first_not_nil + - contains_over_range_nil_comparison + - convenience_type + - discouraged_object_literal + - empty_collection_literal + - empty_count + - empty_string + - empty_xctest_method + - enum_case_associated_values_count + - expiring_todo + - explicit_acl + - explicit_init + - explicit_top_level_acl + # - fallthrough + - fatal_error_message + - file_name + - file_name_no_space + - file_types_order + - first_where + - flatmap_over_map_reduce + - force_unwrapping +# - function_default_parameter_at_end + - ibinspectable_in_extension + - identical_operands + - implicit_return + - implicitly_unwrapped_optional + - indentation_width + - joined_default_parameter + - last_where + - legacy_multiple + - legacy_random + - literal_expression_end_indentation + - lower_acl_than_parent + - missing_docs + - modifier_order + - multiline_arguments + - multiline_arguments_brackets + - multiline_function_chains + - multiline_literal_brackets + - multiline_parameters + - nimble_operator + - nslocalizedstring_key + - nslocalizedstring_require_bundle + - number_separator + - object_literal + - one_declaration_per_file + - operator_usage_whitespace + - optional_enum_case_matching + - overridden_super_call + - override_in_extension + - pattern_matching_keywords + - prefer_self_type_over_type_of_self + - prefer_zero_over_explicit_init + - private_action + - private_outlet + - prohibited_interface_builder + - prohibited_super_call + - quick_discouraged_call + - quick_discouraged_focused_test + - quick_discouraged_pending_test + - reduce_into + - redundant_nil_coalescing + - redundant_type_annotation + - required_enum_case + - single_test_class + - sorted_first_last + - sorted_imports + - static_operator + - strong_iboutlet + - toggle_bool +# - trailing_closure + - type_contents_order + - unavailable_function + - unneeded_parentheses_in_closure_argument + - unowned_variable_capture + - untyped_error_in_catch + - vertical_parameter_alignment_on_call + - vertical_whitespace_closing_braces + - vertical_whitespace_opening_braces + - xct_specific_matcher + - yoda_condition +analyzer_rules: + - unused_import + - unused_declaration +cyclomatic_complexity: + - 6 + - 12 +file_length: + warning: 225 + error: 300 +function_body_length: + - 50 + - 76 +function_parameter_count: 8 +line_length: + - 108 + - 200 +closure_body_length: + - 50 + - 60 +type_name: + min_length: 3 + max_length: + warning: 50 + error: 60 +identifier_name: + excluded: + - id + - no +excluded: + - DerivedData + - .build + - .swiftpm + - "*/.swiftpm" + - "*/.build" + - Mint + - Examples + - Packages + - Package.swift +indentation_width: + indentation_width: 2 +file_name: + severity: error +fatal_error_message: + severity: error +disabled_rules: + - nesting + - implicit_getter + - switch_case_alignment + - closure_parameter_position + - trailing_comma + - opening_brace + - optional_data_string_conversion + - pattern_matching_keywords +custom_rules: + no_unchecked_sendable: + name: "No Unchecked Sendable" + regex: '@unchecked\s+Sendable' + message: "Use proper Sendable conformance instead of @unchecked Sendable to maintain strict concurrency safety" + severity: error diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index ee0c47c..0000000 --- a/.travis.yml +++ /dev/null @@ -1,10 +0,0 @@ -os: linux -language: generic -sudo: required -dist: trusty -env: - - SWIFT_VERSION=5.0 -install: - - eval "$(curl -sL https://swiftenv.fuller.li/install.sh)" -script: - - swift test diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..635ef6d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,12 @@ +# CLAUDE.md — Files + +Vendored package in the BrightDigit Publish stack (`Packages/Publish/Files`), +maintained in-tree on plain Swift 6.4 (`// swift-tools-version:6.4`), macOS 15+. + +- Strict concurrency is mandatory. Resolve every diagnostic properly + (Sendable / isolation / `Synchronization.Mutex`); never use + `@unchecked Sendable` — a SwiftLint `no_unchecked_sendable` rule enforces this. +- Lint: `LINT_MODE=STRICT ./Scripts/lint.sh` — full BrightDigit house style + (swift-format + SwiftLint + build) gates; periphery is local-only (skipped + when `$CI` is set). +- Tests use XCTest (kept as-is). diff --git a/Files.xcodeproj/project.pbxproj b/Files.xcodeproj/project.pbxproj deleted file mode 100644 index c1b1e05..0000000 --- a/Files.xcodeproj/project.pbxproj +++ /dev/null @@ -1,871 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 47; - objects = { - -/* Begin PBXBuildFile section */ - 52D6D9871BEFF229002C0205 /* Files.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 52D6D97C1BEFF229002C0205 /* Files.framework */; }; - 52D6D9981BEFF375002C0205 /* Files.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52D6D9961BEFF375002C0205 /* Files.swift */; }; - 52D6D99B1BEFF375002C0205 /* FilesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52D6D9971BEFF375002C0205 /* FilesTests.swift */; }; - 52D6DA091BF00081002C0205 /* Files.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52D6D9961BEFF375002C0205 /* Files.swift */; }; - 52D6DA261BF00118002C0205 /* Files.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52D6D9961BEFF375002C0205 /* Files.swift */; }; - DD7502861C68FDDC006590AF /* FilesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52D6D9971BEFF375002C0205 /* FilesTests.swift */; }; - DD7502881C68FEDE006590AF /* Files.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 52D6DA0F1BF000BD002C0205 /* Files.framework */; }; - DD7502921C690C7A006590AF /* Files.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 52D6D9F01BEFFFBE002C0205 /* Files.framework */; }; - DD75029A1C690CBE006590AF /* FilesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52D6D9971BEFF375002C0205 /* FilesTests.swift */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 52D6D9881BEFF229002C0205 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 52D6D9731BEFF229002C0205 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 52D6D97B1BEFF229002C0205; - remoteInfo = Files; - }; - DD7502801C68FCFC006590AF /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 52D6D9731BEFF229002C0205 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 52D6DA0E1BF000BD002C0205; - remoteInfo = "Files-macOS"; - }; - DD7502931C690C7A006590AF /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 52D6D9731BEFF229002C0205 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 52D6D9EF1BEFFFBE002C0205; - remoteInfo = "Files-tvOS"; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXFileReference section */ - 52D6D97C1BEFF229002C0205 /* Files.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Files.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 52D6D9861BEFF229002C0205 /* Files-iOS Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Files-iOS Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; - 52D6D9961BEFF375002C0205 /* Files.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Files.swift; path = Sources/Files.swift; sourceTree = ""; }; - 52D6D9971BEFF375002C0205 /* FilesTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = FilesTests.swift; path = Tests/FilesTests/FilesTests.swift; sourceTree = ""; }; - 52D6D9F01BEFFFBE002C0205 /* Files.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Files.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 52D6DA0F1BF000BD002C0205 /* Files.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Files.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - AD2FAA261CD0B6D800659CF4 /* Files.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Files.plist; sourceTree = ""; }; - AD2FAA281CD0B6E100659CF4 /* FilesTests.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = FilesTests.plist; sourceTree = ""; }; - DD75027A1C68FCFC006590AF /* Files-macOS Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Files-macOS Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; - DD75028D1C690C7A006590AF /* Files-tvOS Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Files-tvOS Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 52D6D9781BEFF229002C0205 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 52D6D9831BEFF229002C0205 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 52D6D9871BEFF229002C0205 /* Files.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 52D6D9EC1BEFFFBE002C0205 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 52D6DA0B1BF000BD002C0205 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - DD7502771C68FCFC006590AF /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - DD7502881C68FEDE006590AF /* Files.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - DD75028A1C690C7A006590AF /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - DD7502921C690C7A006590AF /* Files.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 52D6D9721BEFF229002C0205 = { - isa = PBXGroup; - children = ( - 52D6D9961BEFF375002C0205 /* Files.swift */, - 52D6D9971BEFF375002C0205 /* FilesTests.swift */, - 52D6D99C1BEFF38C002C0205 /* Configs */, - 52D6D97D1BEFF229002C0205 /* Products */, - ); - sourceTree = ""; - }; - 52D6D97D1BEFF229002C0205 /* Products */ = { - isa = PBXGroup; - children = ( - 52D6D97C1BEFF229002C0205 /* Files.framework */, - 52D6D9861BEFF229002C0205 /* Files-iOS Tests.xctest */, - 52D6D9F01BEFFFBE002C0205 /* Files.framework */, - 52D6DA0F1BF000BD002C0205 /* Files.framework */, - DD75027A1C68FCFC006590AF /* Files-macOS Tests.xctest */, - DD75028D1C690C7A006590AF /* Files-tvOS Tests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - 52D6D99C1BEFF38C002C0205 /* Configs */ = { - isa = PBXGroup; - children = ( - DD7502721C68FC1B006590AF /* Frameworks */, - DD7502731C68FC20006590AF /* Tests */, - ); - path = Configs; - sourceTree = ""; - }; - DD7502721C68FC1B006590AF /* Frameworks */ = { - isa = PBXGroup; - children = ( - AD2FAA261CD0B6D800659CF4 /* Files.plist */, - ); - name = Frameworks; - sourceTree = ""; - }; - DD7502731C68FC20006590AF /* Tests */ = { - isa = PBXGroup; - children = ( - AD2FAA281CD0B6E100659CF4 /* FilesTests.plist */, - ); - name = Tests; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXHeadersBuildPhase section */ - 52D6D9791BEFF229002C0205 /* Headers */ = { - isa = PBXHeadersBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 52D6D9ED1BEFFFBE002C0205 /* Headers */ = { - isa = PBXHeadersBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 52D6DA0C1BF000BD002C0205 /* Headers */ = { - isa = PBXHeadersBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXHeadersBuildPhase section */ - -/* Begin PBXNativeTarget section */ - 52D6D97B1BEFF229002C0205 /* Files-iOS */ = { - isa = PBXNativeTarget; - buildConfigurationList = 52D6D9901BEFF229002C0205 /* Build configuration list for PBXNativeTarget "Files-iOS" */; - buildPhases = ( - 52D6D9771BEFF229002C0205 /* Sources */, - 52D6D9781BEFF229002C0205 /* Frameworks */, - 52D6D9791BEFF229002C0205 /* Headers */, - 52D6D97A1BEFF229002C0205 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = "Files-iOS"; - productName = Files; - productReference = 52D6D97C1BEFF229002C0205 /* Files.framework */; - productType = "com.apple.product-type.framework"; - }; - 52D6D9851BEFF229002C0205 /* Files-iOS Tests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 52D6D9931BEFF229002C0205 /* Build configuration list for PBXNativeTarget "Files-iOS Tests" */; - buildPhases = ( - 52D6D9821BEFF229002C0205 /* Sources */, - 52D6D9831BEFF229002C0205 /* Frameworks */, - 52D6D9841BEFF229002C0205 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 52D6D9891BEFF229002C0205 /* PBXTargetDependency */, - ); - name = "Files-iOS Tests"; - productName = FilesTests; - productReference = 52D6D9861BEFF229002C0205 /* Files-iOS Tests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - 52D6D9EF1BEFFFBE002C0205 /* Files-tvOS */ = { - isa = PBXNativeTarget; - buildConfigurationList = 52D6DA011BEFFFBE002C0205 /* Build configuration list for PBXNativeTarget "Files-tvOS" */; - buildPhases = ( - 52D6D9EB1BEFFFBE002C0205 /* Sources */, - 52D6D9EC1BEFFFBE002C0205 /* Frameworks */, - 52D6D9ED1BEFFFBE002C0205 /* Headers */, - 52D6D9EE1BEFFFBE002C0205 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = "Files-tvOS"; - productName = "Files-tvOS"; - productReference = 52D6D9F01BEFFFBE002C0205 /* Files.framework */; - productType = "com.apple.product-type.framework"; - }; - 52D6DA0E1BF000BD002C0205 /* Files-macOS */ = { - isa = PBXNativeTarget; - buildConfigurationList = 52D6DA201BF000BD002C0205 /* Build configuration list for PBXNativeTarget "Files-macOS" */; - buildPhases = ( - 52D6DA0A1BF000BD002C0205 /* Sources */, - 52D6DA0B1BF000BD002C0205 /* Frameworks */, - 52D6DA0C1BF000BD002C0205 /* Headers */, - 52D6DA0D1BF000BD002C0205 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = "Files-macOS"; - productName = "Files-macOS"; - productReference = 52D6DA0F1BF000BD002C0205 /* Files.framework */; - productType = "com.apple.product-type.framework"; - }; - DD7502791C68FCFC006590AF /* Files-macOS Tests */ = { - isa = PBXNativeTarget; - buildConfigurationList = DD7502821C68FCFC006590AF /* Build configuration list for PBXNativeTarget "Files-macOS Tests" */; - buildPhases = ( - DD7502761C68FCFC006590AF /* Sources */, - DD7502771C68FCFC006590AF /* Frameworks */, - DD7502781C68FCFC006590AF /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - DD7502811C68FCFC006590AF /* PBXTargetDependency */, - ); - name = "Files-macOS Tests"; - productName = "Files-OS Tests"; - productReference = DD75027A1C68FCFC006590AF /* Files-macOS Tests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - DD75028C1C690C7A006590AF /* Files-tvOS Tests */ = { - isa = PBXNativeTarget; - buildConfigurationList = DD7502951C690C7A006590AF /* Build configuration list for PBXNativeTarget "Files-tvOS Tests" */; - buildPhases = ( - DD7502891C690C7A006590AF /* Sources */, - DD75028A1C690C7A006590AF /* Frameworks */, - DD75028B1C690C7A006590AF /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - DD7502941C690C7A006590AF /* PBXTargetDependency */, - ); - name = "Files-tvOS Tests"; - productName = "Files-tvOS Tests"; - productReference = DD75028D1C690C7A006590AF /* Files-tvOS Tests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 52D6D9731BEFF229002C0205 /* Project object */ = { - isa = PBXProject; - attributes = { - LastSwiftUpdateCheck = 0720; - LastUpgradeCheck = 1020; - ORGANIZATIONNAME = Files; - TargetAttributes = { - 52D6D97B1BEFF229002C0205 = { - CreatedOnToolsVersion = 7.1; - LastSwiftMigration = 1020; - }; - 52D6D9851BEFF229002C0205 = { - CreatedOnToolsVersion = 7.1; - LastSwiftMigration = 1020; - }; - 52D6D9EF1BEFFFBE002C0205 = { - CreatedOnToolsVersion = 7.1; - LastSwiftMigration = 0800; - }; - 52D6DA0E1BF000BD002C0205 = { - CreatedOnToolsVersion = 7.1; - LastSwiftMigration = 1020; - }; - DD7502791C68FCFC006590AF = { - CreatedOnToolsVersion = 7.2.1; - LastSwiftMigration = 1020; - }; - DD75028C1C690C7A006590AF = { - CreatedOnToolsVersion = 7.2.1; - LastSwiftMigration = 0800; - }; - }; - }; - buildConfigurationList = 52D6D9761BEFF229002C0205 /* Build configuration list for PBXProject "Files" */; - compatibilityVersion = "Xcode 6.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 52D6D9721BEFF229002C0205; - productRefGroup = 52D6D97D1BEFF229002C0205 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 52D6D97B1BEFF229002C0205 /* Files-iOS */, - 52D6DA0E1BF000BD002C0205 /* Files-macOS */, - 52D6D9EF1BEFFFBE002C0205 /* Files-tvOS */, - 52D6D9851BEFF229002C0205 /* Files-iOS Tests */, - DD7502791C68FCFC006590AF /* Files-macOS Tests */, - DD75028C1C690C7A006590AF /* Files-tvOS Tests */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 52D6D97A1BEFF229002C0205 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 52D6D9841BEFF229002C0205 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 52D6D9EE1BEFFFBE002C0205 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 52D6DA0D1BF000BD002C0205 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - DD7502781C68FCFC006590AF /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - DD75028B1C690C7A006590AF /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 52D6D9771BEFF229002C0205 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 52D6D9981BEFF375002C0205 /* Files.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 52D6D9821BEFF229002C0205 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 52D6D99B1BEFF375002C0205 /* FilesTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 52D6D9EB1BEFFFBE002C0205 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 52D6DA091BF00081002C0205 /* Files.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 52D6DA0A1BF000BD002C0205 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 52D6DA261BF00118002C0205 /* Files.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - DD7502761C68FCFC006590AF /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - DD7502861C68FDDC006590AF /* FilesTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - DD7502891C690C7A006590AF /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - DD75029A1C690CBE006590AF /* FilesTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 52D6D9891BEFF229002C0205 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 52D6D97B1BEFF229002C0205 /* Files-iOS */; - targetProxy = 52D6D9881BEFF229002C0205 /* PBXContainerItemProxy */; - }; - DD7502811C68FCFC006590AF /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 52D6DA0E1BF000BD002C0205 /* Files-macOS */; - targetProxy = DD7502801C68FCFC006590AF /* PBXContainerItemProxy */; - }; - DD7502941C690C7A006590AF /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 52D6D9EF1BEFFFBE002C0205 /* Files-tvOS */; - targetProxy = DD7502931C690C7A006590AF /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin XCBuildConfiguration section */ - 52D6D98E1BEFF229002C0205 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = ""; - VERSIONING_SYSTEM = "apple-generic"; - VERSION_INFO_PREFIX = ""; - }; - name = Debug; - }; - 52D6D98F1BEFF229002C0205 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = ""; - VALIDATE_PRODUCT = YES; - VERSIONING_SYSTEM = "apple-generic"; - VERSION_INFO_PREFIX = ""; - }; - name = Release; - }; - 52D6D9911BEFF229002C0205 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - APPLICATION_EXTENSION_API_ONLY = YES; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_IDENTITY = "iPhone Developer"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; - DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = ""; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - INFOPLIST_FILE = Configs/Files.plist; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - ONLY_ACTIVE_ARCH = NO; - PRODUCT_BUNDLE_IDENTIFIER = "com.Files.Files-iOS"; - PRODUCT_NAME = Files; - SKIP_INSTALL = YES; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 52D6D9921BEFF229002C0205 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - APPLICATION_EXTENSION_API_ONLY = YES; - CLANG_ENABLE_MODULES = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; - DEFINES_MODULE = YES; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - INFOPLIST_FILE = Configs/Files.plist; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = "com.Files.Files-iOS"; - PRODUCT_NAME = Files; - SKIP_INSTALL = YES; - SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - 52D6D9941BEFF229002C0205 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CLANG_ENABLE_MODULES = YES; - DEVELOPMENT_TEAM = ""; - INFOPLIST_FILE = Configs/FilesTests.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = "com.Files.Files-iOS-Tests"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 52D6D9951BEFF229002C0205 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CLANG_ENABLE_MODULES = YES; - DEVELOPMENT_TEAM = ""; - INFOPLIST_FILE = Configs/FilesTests.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = "com.Files.Files-iOS-Tests"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - 52D6DA021BEFFFBE002C0205 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - APPLICATION_EXTENSION_API_ONLY = YES; - "CODE_SIGN_IDENTITY[sdk=appletvos*]" = ""; - DEFINES_MODULE = YES; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - INFOPLIST_FILE = Configs/Files.plist; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = "com.Files.Files-tvOS"; - PRODUCT_NAME = Files; - SDKROOT = appletvos; - SKIP_INSTALL = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 3; - TVOS_DEPLOYMENT_TARGET = 9.0; - }; - name = Debug; - }; - 52D6DA031BEFFFBE002C0205 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - APPLICATION_EXTENSION_API_ONLY = YES; - "CODE_SIGN_IDENTITY[sdk=appletvos*]" = ""; - DEFINES_MODULE = YES; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - INFOPLIST_FILE = Configs/Files.plist; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = "com.Files.Files-tvOS"; - PRODUCT_NAME = Files; - SDKROOT = appletvos; - SKIP_INSTALL = YES; - SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 3; - TVOS_DEPLOYMENT_TARGET = 9.0; - }; - name = Release; - }; - 52D6DA211BF000BD002C0205 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - APPLICATION_EXTENSION_API_ONLY = YES; - CODE_SIGN_IDENTITY = "-"; - COMBINE_HIDPI_IMAGES = YES; - DEFINES_MODULE = YES; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - FRAMEWORK_VERSION = A; - INFOPLIST_FILE = Configs/Files.plist; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 10.10; - PRODUCT_BUNDLE_IDENTIFIER = "com.Files.Files-macOS"; - PRODUCT_NAME = Files; - SDKROOT = macosx; - SKIP_INSTALL = YES; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 52D6DA221BF000BD002C0205 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - APPLICATION_EXTENSION_API_ONLY = YES; - CODE_SIGN_IDENTITY = "-"; - COMBINE_HIDPI_IMAGES = YES; - DEFINES_MODULE = YES; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - FRAMEWORK_VERSION = A; - INFOPLIST_FILE = Configs/Files.plist; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 10.10; - PRODUCT_BUNDLE_IDENTIFIER = "com.Files.Files-macOS"; - PRODUCT_NAME = Files; - SDKROOT = macosx; - SKIP_INSTALL = YES; - SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - DD7502831C68FCFC006590AF /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_IDENTITY = "-"; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Configs/FilesTests.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 10.11; - PRODUCT_BUNDLE_IDENTIFIER = "com.Files.Files-macOS-Tests"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = macosx; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - DD7502841C68FCFC006590AF /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_IDENTITY = "-"; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Configs/FilesTests.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 10.11; - PRODUCT_BUNDLE_IDENTIFIER = "com.Files.Files-macOS-Tests"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = macosx; - SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - DD7502961C690C7A006590AF /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - INFOPLIST_FILE = Configs/FilesTests.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = "com.Files.Files-tvOS-Tests"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = appletvos; - SWIFT_VERSION = 5.0; - TVOS_DEPLOYMENT_TARGET = 9.1; - }; - name = Debug; - }; - DD7502971C690C7A006590AF /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - INFOPLIST_FILE = Configs/FilesTests.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = "com.Files.Files-tvOS-Tests"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = appletvos; - SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; - SWIFT_VERSION = 5.0; - TVOS_DEPLOYMENT_TARGET = 9.1; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 52D6D9761BEFF229002C0205 /* Build configuration list for PBXProject "Files" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 52D6D98E1BEFF229002C0205 /* Debug */, - 52D6D98F1BEFF229002C0205 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 52D6D9901BEFF229002C0205 /* Build configuration list for PBXNativeTarget "Files-iOS" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 52D6D9911BEFF229002C0205 /* Debug */, - 52D6D9921BEFF229002C0205 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 52D6D9931BEFF229002C0205 /* Build configuration list for PBXNativeTarget "Files-iOS Tests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 52D6D9941BEFF229002C0205 /* Debug */, - 52D6D9951BEFF229002C0205 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 52D6DA011BEFFFBE002C0205 /* Build configuration list for PBXNativeTarget "Files-tvOS" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 52D6DA021BEFFFBE002C0205 /* Debug */, - 52D6DA031BEFFFBE002C0205 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 52D6DA201BF000BD002C0205 /* Build configuration list for PBXNativeTarget "Files-macOS" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 52D6DA211BF000BD002C0205 /* Debug */, - 52D6DA221BF000BD002C0205 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - DD7502821C68FCFC006590AF /* Build configuration list for PBXNativeTarget "Files-macOS Tests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - DD7502831C68FCFC006590AF /* Debug */, - DD7502841C68FCFC006590AF /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - DD7502951C690C7A006590AF /* Build configuration list for PBXNativeTarget "Files-tvOS Tests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - DD7502961C690C7A006590AF /* Debug */, - DD7502971C690C7A006590AF /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 52D6D9731BEFF229002C0205 /* Project object */; -} diff --git a/Files.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Files.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a..0000000 --- a/Files.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/Files.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Files.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d9810..0000000 --- a/Files.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/Files.xcodeproj/xcshareddata/xcschemes/Files-iOS.xcscheme b/Files.xcodeproj/xcshareddata/xcschemes/Files-iOS.xcscheme deleted file mode 100644 index 23cd5f1..0000000 --- a/Files.xcodeproj/xcshareddata/xcschemes/Files-iOS.xcscheme +++ /dev/null @@ -1,100 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Files.xcodeproj/xcshareddata/xcschemes/Files-macOS.xcscheme b/Files.xcodeproj/xcshareddata/xcschemes/Files-macOS.xcscheme deleted file mode 100644 index 9b933b4..0000000 --- a/Files.xcodeproj/xcshareddata/xcschemes/Files-macOS.xcscheme +++ /dev/null @@ -1,100 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Files.xcodeproj/xcshareddata/xcschemes/Files-tvOS.xcscheme b/Files.xcodeproj/xcshareddata/xcschemes/Files-tvOS.xcscheme deleted file mode 100644 index d516bb4..0000000 --- a/Files.xcodeproj/xcshareddata/xcschemes/Files-tvOS.xcscheme +++ /dev/null @@ -1,100 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Package.swift b/Package.swift index cc7dc34..b90b2fa 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.0 +// swift-tools-version:6.4 /** * Files @@ -10,6 +10,13 @@ import PackageDescription let package = Package( name: "Files", + // Mutex (Synchronization) requires iOS 18 / tvOS 18 / watchOS 11. + platforms: [ + .macOS(.v15), + .iOS(.v18), + .tvOS(.v18), + .watchOS(.v11) + ], products: [ .library(name: "Files", targets: ["Files"]) ], diff --git a/Scripts/header.sh b/Scripts/header.sh new file mode 100755 index 0000000..c571c18 --- /dev/null +++ b/Scripts/header.sh @@ -0,0 +1,104 @@ +#!/bin/bash + +# Function to print usage +usage() { + echo "Usage: $0 -d directory -c creator -o company -p package [-y year]" + echo " -d directory Directory to read from (including subdirectories)" + echo " -c creator Name of the creator" + echo " -o company Name of the company with the copyright" + echo " -p package Package or library name" + echo " -y year Copyright year (optional, defaults to current year)" + exit 1 +} + +# Get the current year if not provided +current_year=$(date +"%Y") + +# Default values +year="$current_year" + +# Parse arguments +while getopts ":d:c:o:p:y:" opt; do + case $opt in + d) directory="$OPTARG" ;; + c) creator="$OPTARG" ;; + o) company="$OPTARG" ;; + p) package="$OPTARG" ;; + y) year="$OPTARG" ;; + *) usage ;; + esac +done + +# Check for mandatory arguments +if [ -z "$directory" ] || [ -z "$creator" ] || [ -z "$company" ] || [ -z "$package" ]; then + usage +fi + +# Define the header template +header_template="// +// %s +// %s +// +// Created by %s. +// Copyright © %s %s. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the \"Software\"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +//" + +# Loop through each Swift file in the specified directory and subdirectories +find "$directory" -type f -name "*.swift" | while read -r file; do + # Skip files in the Generated directory + if [[ "$file" == *"/Generated/"* ]]; then + echo "Skipping $file (generated file)" + continue + fi + + # Check if the first line is the swift-format-ignore indicator + first_line=$(head -n 1 "$file") + if [[ "$first_line" == "// swift-format-ignore-file" ]]; then + echo "Skipping $file due to swift-format-ignore directive." + continue + fi + + # Create the header with the current filename + filename=$(basename "$file") + header=$(printf "$header_template" "$filename" "$package" "$creator" "$year" "$company") + + # Remove all consecutive lines at the beginning which start with "// ", contain only whitespace, or only "//" + awk ' + BEGIN { skip = 1 } + { + if (skip && ($0 ~ /^\/\/ / || $0 ~ /^\/\/$/ || $0 ~ /^$/)) { + next + } + skip = 0 + print + }' "$file" > temp_file + + # Add the header to the cleaned file + (echo "$header"; echo; cat temp_file) > "$file" + + # Remove the temporary file + rm temp_file +done + +echo "Headers added or files skipped appropriately across all Swift files in the directory and subdirectories." diff --git a/Scripts/lint.sh b/Scripts/lint.sh new file mode 100755 index 0000000..22276ee --- /dev/null +++ b/Scripts/lint.sh @@ -0,0 +1,101 @@ +#!/bin/bash + +# Remove set -e to allow script to continue running +# set -e # Exit on any error + +ERRORS=0 + +run_command() { + "$@" || ERRORS=$((ERRORS + 1)) +} + +if [ "$LINT_MODE" = "INSTALL" ]; then + exit +fi + +echo "LintMode: $LINT_MODE" + +# More portable way to get script directory +if [ -z "$SRCROOT" ]; then + SCRIPT_DIR=$(dirname "$(readlink -f "$0")") + PACKAGE_DIR="${SCRIPT_DIR}/.." +else + PACKAGE_DIR="${SRCROOT}" +fi + +# Detect if mise is available +# Check common installation paths for mise +MISE_PATHS=( + "/opt/homebrew/bin/mise" + "/usr/local/bin/mise" + "$HOME/.local/bin/mise" +) + +MISE_BIN="" +for mise_path in "${MISE_PATHS[@]}"; do + if [ -x "$mise_path" ]; then + MISE_BIN="$mise_path" + break + fi +done + +# Fallback to PATH lookup +if [ -z "$MISE_BIN" ] && command -v mise &> /dev/null; then + MISE_BIN="mise" +fi + +if [ -n "$MISE_BIN" ]; then + TOOL_CMD="$MISE_BIN exec --" +else + echo "Error: mise is not installed" + echo "Install mise: https://mise.jdx.dev/getting-started.html" + echo "Checked paths: ${MISE_PATHS[*]}" + exit 1 +fi + +if [ "$LINT_MODE" = "NONE" ]; then + exit +elif [ "$LINT_MODE" = "STRICT" ]; then + SWIFTFORMAT_LINT_OPTIONS="--strict" + SWIFTLINT_OPTIONS="--strict" +else + SWIFTFORMAT_LINT_OPTIONS="" + SWIFTLINT_OPTIONS="" +fi + +pushd $PACKAGE_DIR + +# Bootstrap tools (mise will install based on .mise.toml) +run_command "$MISE_BIN" install + +if [ -z "$CI" ]; then + run_command $TOOL_CMD swift-format format --configuration .swift-format --recursive --parallel --in-place Sources Tests + run_command $TOOL_CMD swiftlint --fix +fi + +if [ -z "$FORMAT_ONLY" ]; then + run_command $TOOL_CMD swift-format lint --configuration .swift-format --recursive --parallel $SWIFTFORMAT_LINT_OPTIONS Sources Tests + run_command $TOOL_CMD swiftlint lint $SWIFTLINT_OPTIONS + # Check for compilation errors + run_command swift build --build-tests +fi + +# header.sh rewrites file headers in place, so it only runs locally — never in CI. +if [ -z "$CI" ]; then + $PACKAGE_DIR/Scripts/header.sh -d $PACKAGE_DIR/Sources -c "Leo Dion" -o "BrightDigit" -p "Files" +fi + +if [ -z "$CI" ]; then + run_command $TOOL_CMD periphery scan $PERIPHERY_OPTIONS --disable-update-check +fi + +popd + +# Exit with error code if any errors occurred +if [ $ERRORS -gt 0 ]; then + echo "Linting completed with $ERRORS error(s)" + exit 1 +else + echo "Linting completed successfully" + exit 0 +fi diff --git a/Sources/File.swift b/Sources/File.swift new file mode 100644 index 0000000..e61f196 --- /dev/null +++ b/Sources/File.swift @@ -0,0 +1,147 @@ +// +// File.swift +// +// Copyright (c) 2017-2019 John Sundell. +// Licensed under the MIT license, as follows: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +/// Type that represents a file on disk. You can either reference an existing +/// file by initializing an instance with a `path`, or you can create new files +/// using the various `createFile...` APIs available on `Folder`. +public struct File: Location { + /// The underlying storage for the item at the represented location. + public let storage: Storage + + /// Initialize an instance of this file with its underlying storage. + /// - parameter storage: The storage backing this file. + public init(storage: Storage) { + self.storage = storage + } +} + +extension File { + /// The kind of location that is being represented. + public static var kind: LocationKind { + .file + } + + /// Write a new set of binary data into the file, replacing its current contents. + /// - parameter data: The binary data to write. + /// - throws: `WriteError` in case the operation couldn't be completed. + public func write(_ data: Data) throws(WriteError) { + do { + try data.write(to: url) + } catch { + throw WriteError(path: path, reason: .writeFailed(error)) + } + } + + /// Write a new string into the file, replacing its current contents. + /// - Parameters: + /// - string: The string to write. + /// - encoding: The encoding of the string (default: `UTF8`). + /// - throws: `WriteError` in case the operation couldn't be completed. + public func write(_ string: String, encoding: String.Encoding = .utf8) throws(WriteError) { + guard let data = string.data(using: encoding) else { + throw WriteError(path: path, reason: .stringEncodingFailed(string)) + } + + return try write(data) + } + + /// Append a set of binary data to the file's existing contents. + /// - parameter data: The binary data to append. + /// - throws: `WriteError` in case the operation couldn't be completed. + public func append(_ data: Data) throws(WriteError) { + do { + let handle = try FileHandle(forWritingTo: url) + handle.seekToEndOfFile() + handle.write(data) + handle.closeFile() + } catch { + throw WriteError(path: path, reason: .writeFailed(error)) + } + } + + /// Append a string to the file's existing contents. + /// - Parameters: + /// - string: The string to append. + /// - encoding: The encoding of the string (default: `UTF8`). + /// - throws: `WriteError` in case the operation couldn't be completed. + public func append(_ string: String, encoding: String.Encoding = .utf8) throws(WriteError) { + guard let data = string.data(using: encoding) else { + throw WriteError(path: path, reason: .stringEncodingFailed(string)) + } + + return try append(data) + } + + /// Read the contents of the file as binary data. + /// - throws: `ReadError` if the file couldn't be read. + public func read() throws(ReadError) -> Data { + do { return try Data(contentsOf: url) } catch { + throw ReadError(path: path, reason: .readFailed(error)) + } + } + + /// Read the contents of the file as a string. + /// - parameter encoding: The encoding to decode the file's data using (default: `UTF8`). + /// - throws: `ReadError` if the file couldn't be read, or if a string couldn't + /// be decoded from the file's contents. + /// - returns: The decoded string contents of the file. + public func readAsString(encodedAs encoding: String.Encoding = .utf8) throws(ReadError) -> String + { + guard let string = try String(data: read(), encoding: encoding) else { + throw ReadError(path: path, reason: .stringDecodingFailed) + } + + return string + } + + /// Read the contents of the file as an integer. + /// - throws: `ReadError` if the file couldn't be read, or if the file's + /// contents couldn't be converted into an integer. + /// - returns: The integer parsed from the file's contents. + public func readAsInt() throws(ReadError) -> Int { + let string = try readAsString() + + guard let int = Int(string) else { + throw ReadError(path: path, reason: .notAnInt(string)) + } + + return int + } +} + +#if canImport(AppKit) && !targetEnvironment(macCatalyst) + + import AppKit + + extension File { + /// Open the file. + public func open() { + NSWorkspace.shared.open(URL(fileURLWithPath: path)) + } + } + +#endif diff --git a/Sources/FileManager+Files.swift b/Sources/FileManager+Files.swift new file mode 100644 index 0000000..b75bb63 --- /dev/null +++ b/Sources/FileManager+Files.swift @@ -0,0 +1,42 @@ +// +// FileManager+Files.swift +// +// Copyright (c) 2017-2019 John Sundell. +// Licensed under the MIT license, as follows: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +extension FileManager { + internal func locationExists(at path: String, kind: LocationKind) -> Bool { + var isFolder: ObjCBool = false + + // `path` is in canonical (forward-slash) form; convert at the FileManager boundary. + guard fileExists(atPath: path.nativePath, isDirectory: &isFolder) else { + return false + } + + switch kind { + case .file: return !isFolder.boolValue + case .folder: return isFolder.boolValue + } + } +} diff --git a/Sources/Files.swift b/Sources/Files.swift deleted file mode 100644 index aede19e..0000000 --- a/Sources/Files.swift +++ /dev/null @@ -1,1047 +0,0 @@ -/** - * Files - * - * Copyright (c) 2017-2019 John Sundell. Licensed under the MIT license, as follows: - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -import Foundation - -// MARK: - Locations - -/// Enum describing various kinds of locations that can be found on a file system. -public enum LocationKind { - /// A file can be found at the location. - case file - /// A folder can be found at the location. - case folder -} - -/// Protocol adopted by types that represent locations on a file system. -public protocol Location: Equatable, CustomStringConvertible { - /// The kind of location that is being represented (see `LocationKind`). - static var kind: LocationKind { get } - /// The underlying storage for the item at the represented location. - /// You don't interact with this object as part of the public API. - var storage: Storage { get } - /// Initialize an instance of this location with its underlying storage. - /// You don't call this initializer as part of the public API, instead - /// use `init(path:)` on either `File` or `Folder`. - init(storage: Storage) -} - -public extension Location { - static func ==(lhs: Self, rhs: Self) -> Bool { - return lhs.storage.path == rhs.storage.path - } - - var description: String { - let typeName = String(describing: type(of: self)) - return "\(typeName)(name: \(name), path: \(path))" - } - - /// The path of this location, relative to the root of the file system. - var path: String { - return storage.path - } - - /// A URL representation of the location's `path`. - var url: URL { - return URL(fileURLWithPath: path) - } - - /// The name of the location, including any `extension`. - var name: String { - return url.pathComponents.last! - } - - /// The name of the location, excluding its `extension`. - var nameExcludingExtension: String { - let components = name.split(separator: ".") - guard components.count > 1 else { return name } - return components.dropLast().joined() - } - - /// The file extension of the item at the location. - var `extension`: String? { - let components = name.split(separator: ".") - guard components.count > 1 else { return nil } - return String(components.last!) - } - - /// The parent folder that this location is contained within. - var parent: Folder? { - return storage.makeParentPath(for: path).flatMap { - try? Folder(path: $0) - } - } - - /// The date when the item at this location was created. - /// Only returns `nil` in case the item has now been deleted. - var creationDate: Date? { - return storage.attributes[.creationDate] as? Date - } - - /// The date when the item at this location was last modified. - /// Only returns `nil` in case the item has now been deleted. - var modificationDate: Date? { - return storage.attributes[.modificationDate] as? Date - } - - /// Initialize an instance of an existing location at a given path. - /// - parameter path: The absolute path of the location. - /// - throws: `LocationError` if the item couldn't be found. - init(path: String) throws { - try self.init(storage: Storage( - path: path, - fileManager: .default - )) - } - - /// Return the path of this location relative to a parent folder. - /// For example, if this item is located at `/users/john/documents` - /// and `/users/john` is passed, then `documents` is returned. If the - /// passed folder isn't an ancestor of this item, then the item's - /// absolute `path` is returned instead. - /// - parameter folder: The folder to compare this item's path against. - func path(relativeTo folder: Folder) -> String { - guard path.hasPrefix(folder.path) else { - return path - } - - let index = path.index(path.startIndex, offsetBy: folder.path.count) - return String(path[index...]).removingSuffix("/") - } - - /// Rename this location, keeping its existing `extension` by default. - /// - parameter newName: The new name to give the location. - /// - parameter keepExtension: Whether the location's `extension` should - /// remain unmodified (default: `true`). - /// - throws: `LocationError` if the item couldn't be renamed. - func rename(to newName: String, keepExtension: Bool = true) throws { - guard let parent = parent else { - throw LocationError(path: path, reason: .cannotRenameRoot) - } - - var newName = newName - - if keepExtension { - `extension`.map { - newName = newName.appendingSuffixIfNeeded(".\($0)") - } - } - - try storage.move( - to: parent.path + newName, - errorReasonProvider: LocationErrorReason.renameFailed - ) - } - - /// Move this location to a new parent folder - /// - parameter newParent: The folder to move this item to. - /// - throws: `LocationError` if the location couldn't be moved. - func move(to newParent: Folder) throws { - try storage.move( - to: newParent.path + name, - errorReasonProvider: LocationErrorReason.moveFailed - ) - } - - /// Copy the contents of this location to a given folder - /// - parameter newParent: The folder to copy this item to. - /// - throws: `LocationError` if the location couldn't be copied. - /// - returns: The new, copied location. - @discardableResult - func copy(to folder: Folder) throws -> Self { - let path = folder.path + name - try storage.copy(to: path) - return try Self(path: path) - } - - /// Delete this location. It will be permanently deleted. Use with caution. - /// - throws: `LocationError` if the item couldn't be deleted. - func delete() throws { - try storage.delete() - } - - /// Assign a new `FileManager` to manage this location. Typically only used - /// for testing, or when building custom file systems. Returns a new instance, - /// doensn't modify the instance this is called on. - /// - parameter manager: The new file manager that should manage this location. - /// - throws: `LocationError` if the change couldn't be completed. - func managedBy(_ manager: FileManager) throws -> Self { - return try Self(storage: Storage( - path: path, - fileManager: manager - )) - } -} - -// MARK: - Storage - -/// Type used to store information about a given file system location. You don't -/// interact with this type as part of the public API, instead you use the APIs -/// exposed by `Location`, `File`, and `Folder`. -public final class Storage { - fileprivate private(set) var path: String - private let fileManager: FileManager - - fileprivate init(path: String, fileManager: FileManager) throws { - self.path = path - self.fileManager = fileManager - try validatePath() - } - - private func validatePath() throws { - switch LocationType.kind { - case .file: - guard !path.isEmpty else { - throw LocationError(path: path, reason: .emptyFilePath) - } - case .folder: - if path.isEmpty { path = fileManager.currentDirectoryPath } - if !path.hasSuffix("/") { path += "/" } - } - - if path.hasPrefix("~") { - let homePath = ProcessInfo.processInfo.environment["HOME"]! - path = homePath + path.dropFirst() - } - - while let parentReferenceRange = path.range(of: "../") { - let folderPath = String(path[.. String? { - guard path != "/" else { return nil } - let url = URL(fileURLWithPath: path) - let components = url.pathComponents.dropFirst().dropLast() - guard !components.isEmpty else { return "/" } - return "/" + components.joined(separator: "/") + "/" - } - - func move(to newPath: String, - errorReasonProvider: (Error) -> LocationErrorReason) throws { - do { - try fileManager.moveItem(atPath: path, toPath: newPath) - - switch LocationType.kind { - case .file: - path = newPath - case .folder: - path = newPath.appendingSuffixIfNeeded("/") - } - } catch { - throw LocationError(path: path, reason: errorReasonProvider(error)) - } - } - - func copy(to newPath: String) throws { - do { - try fileManager.copyItem(at: URL(fileURLWithPath: path), - to: URL(fileURLWithPath: newPath)) - - } catch { - throw LocationError(path: path, reason: .copyFailed(error)) - } - } - - func delete() throws { - do { - try fileManager.removeItem(atPath: path) - } catch { - throw LocationError(path: path, reason: .deleteFailed(error)) - } - } -} - -private extension Storage where LocationType == Folder { - func makeChildSequence() -> Folder.ChildSequence { - return Folder.ChildSequence( - folder: Folder(storage: self), - fileManager: fileManager, - isRecursive: false, - includeHidden: false - ) - } - - func subfolder(at folderPath: String) throws -> Folder { - let folderPath = path + folderPath.removingPrefix("/") - let storage = try Storage(path: folderPath, fileManager: fileManager) - return Folder(storage: storage) - } - - func file(at filePath: String) throws -> File { - let filePath = path + filePath.removingPrefix("/") - let storage = try Storage(path: filePath, fileManager: fileManager) - return File(storage: storage) - } - - func createSubfolder(at folderPath: String) throws -> Folder { - let folderPath = path + folderPath.removingPrefix("/") - - guard folderPath != path else { - throw WriteError(path: folderPath, reason: .emptyPath) - } - - do { - try fileManager.createDirectory( - atPath: folderPath, - withIntermediateDirectories: true - ) - - let storage = try Storage(path: folderPath, fileManager: fileManager) - return Folder(storage: storage) - } catch { - throw WriteError(path: folderPath, reason: .folderCreationFailed(error)) - } - } - - func createFile(at filePath: String, contents: Data?) throws -> File { - let filePath = path + filePath.removingPrefix("/") - - guard let parentPath = makeParentPath(for: filePath) else { - throw WriteError(path: filePath, reason: .emptyPath) - } - - if parentPath != path { - do { - try fileManager.createDirectory( - atPath: parentPath, - withIntermediateDirectories: true - ) - } catch { - throw WriteError(path: parentPath, reason: .folderCreationFailed(error)) - } - } - - guard fileManager.createFile(atPath: filePath, contents: contents), - let storage = try? Storage(path: filePath, fileManager: fileManager) else { - throw WriteError(path: filePath, reason: .fileCreationFailed) - } - - return File(storage: storage) - } -} - -// MARK: - Files - -/// Type that represents a file on disk. You can either reference an existing -/// file by initializing an instance with a `path`, or you can create new files -/// using the various `createFile...` APIs available on `Folder`. -public struct File: Location { - public let storage: Storage - - public init(storage: Storage) { - self.storage = storage - } -} - -public extension File { - static var kind: LocationKind { - return .file - } - - /// Write a new set of binary data into the file, replacing its current contents. - /// - parameter data: The binary data to write. - /// - throws: `WriteError` in case the operation couldn't be completed. - func write(_ data: Data) throws { - do { - try data.write(to: url) - } catch { - throw WriteError(path: path, reason: .writeFailed(error)) - } - } - - /// Write a new string into the file, replacing its current contents. - /// - parameter string: The string to write. - /// - parameter encoding: The encoding of the string (default: `UTF8`). - /// - throws: `WriteError` in case the operation couldn't be completed. - func write(_ string: String, encoding: String.Encoding = .utf8) throws { - guard let data = string.data(using: encoding) else { - throw WriteError(path: path, reason: .stringEncodingFailed(string)) - } - - return try write(data) - } - - /// Append a set of binary data to the file's existing contents. - /// - parameter data: The binary data to append. - /// - throws: `WriteError` in case the operation couldn't be completed. - func append(_ data: Data) throws { - do { - let handle = try FileHandle(forWritingTo: url) - handle.seekToEndOfFile() - handle.write(data) - handle.closeFile() - } catch { - throw WriteError(path: path, reason: .writeFailed(error)) - } - } - - /// Append a string to the file's existing contents. - /// - parameter string: The string to append. - /// - parameter encoding: The encoding of the string (default: `UTF8`). - /// - throws: `WriteError` in case the operation couldn't be completed. - func append(_ string: String, encoding: String.Encoding = .utf8) throws { - guard let data = string.data(using: encoding) else { - throw WriteError(path: path, reason: .stringEncodingFailed(string)) - } - - return try append(data) - } - - /// Read the contents of the file as binary data. - /// - throws: `ReadError` if the file couldn't be read. - func read() throws -> Data { - do { return try Data(contentsOf: url) } - catch { throw ReadError(path: path, reason: .readFailed(error)) } - } - - /// Read the contents of the file as a string. - /// - parameter encoding: The encoding to decode the file's data using (default: `UTF8`). - /// - throws: `ReadError` if the file couldn't be read, or if a string couldn't - /// be decoded from the file's contents. - func readAsString(encodedAs encoding: String.Encoding = .utf8) throws -> String { - guard let string = try String(data: read(), encoding: encoding) else { - throw ReadError(path: path, reason: .stringDecodingFailed) - } - - return string - } - - /// Read the contents of the file as an integer. - /// - throws: `ReadError` if the file couldn't be read, or if the file's - /// contents couldn't be converted into an integer. - func readAsInt() throws -> Int { - let string = try readAsString() - - guard let int = Int(string) else { - throw ReadError(path: path, reason: .notAnInt(string)) - } - - return int - } -} - -#if canImport(AppKit) && !targetEnvironment(macCatalyst) - -import AppKit - -public extension File { - /// Open the file. - func open() { - NSWorkspace.shared.openFile(path) - } -} - -#endif - -// MARK: - Folders - -/// Type that represents a folder on disk. You can either reference an existing -/// folder by initializing an instance with a `path`, or you can create new -/// subfolders using this type's various `createSubfolder...` APIs. -public struct Folder: Location { - public let storage: Storage - - public init(storage: Storage) { - self.storage = storage - } -} - -public extension Folder { - /// A sequence of child locations contained within a given folder. - /// You obtain an instance of this type by accessing either `files` - /// or `subfolders` on a `Folder` instance. - struct ChildSequence: Sequence { - fileprivate let folder: Folder - fileprivate let fileManager: FileManager - fileprivate var isRecursive: Bool - fileprivate var includeHidden: Bool - - public func makeIterator() -> ChildIterator { - return ChildIterator( - folder: folder, - fileManager: fileManager, - isRecursive: isRecursive, - includeHidden: includeHidden, - reverseTopLevelTraversal: false - ) - } - } - - /// The type of iterator used by `ChildSequence`. You don't interact - /// with this type directly. See `ChildSequence` for more information. - struct ChildIterator: IteratorProtocol { - private let folder: Folder - private let fileManager: FileManager - private let isRecursive: Bool - private let includeHidden: Bool - private let reverseTopLevelTraversal: Bool - private lazy var itemNames = loadItemNames() - private var index = 0 - private var nestedIterators = [ChildIterator]() - - fileprivate init(folder: Folder, - fileManager: FileManager, - isRecursive: Bool, - includeHidden: Bool, - reverseTopLevelTraversal: Bool) { - self.folder = folder - self.fileManager = fileManager - self.isRecursive = isRecursive - self.includeHidden = includeHidden - self.reverseTopLevelTraversal = reverseTopLevelTraversal - } - - public mutating func next() -> Child? { - guard index < itemNames.count else { - guard var nested = nestedIterators.first else { - return nil - } - - guard let child = nested.next() else { - nestedIterators.removeFirst() - return next() - } - - nestedIterators[0] = nested - return child - } - - let name = itemNames[index] - index += 1 - - if !includeHidden { - guard !name.hasPrefix(".") else { return next() } - } - - let childPath = folder.path + name.removingPrefix("/") - let childStorage = try? Storage(path: childPath, fileManager: fileManager) - let child = childStorage.map(Child.init) - - if isRecursive { - let childFolder = (child as? Folder) ?? (try? Folder( - storage: Storage(path: childPath, fileManager: fileManager) - )) - - if let childFolder = childFolder { - let nested = ChildIterator( - folder: childFolder, - fileManager: fileManager, - isRecursive: true, - includeHidden: includeHidden, - reverseTopLevelTraversal: false - ) - - nestedIterators.append(nested) - } - } - - return child ?? next() - } - - private mutating func loadItemNames() -> [String] { - let contents = try? fileManager.contentsOfDirectory(atPath: folder.path) - let names = contents?.sorted() ?? [] - return reverseTopLevelTraversal ? names.reversed() : names - } - } -} - -extension Folder.ChildSequence: CustomStringConvertible { - public var description: String { - return lazy.map({ $0.description }).joined(separator: "\n") - } -} - -public extension Folder.ChildSequence { - /// Return a new instance of this sequence that'll traverse the folder's - /// contents recursively, in a breadth-first manner. Complexity: `O(1)`. - var recursive: Folder.ChildSequence { - var sequence = self - sequence.isRecursive = true - return sequence - } - - /// Return a new instance of this sequence that'll include all hidden - /// (dot) files when traversing the folder's contents. Complexity: `O(1)`. - var includingHidden: Folder.ChildSequence { - var sequence = self - sequence.includeHidden = true - return sequence - } - - /// Count the number of locations contained within this sequence. - /// Complexity: `O(N)`. - func count() -> Int { - return reduce(0) { count, _ in count + 1 } - } - - /// Gather the names of all of the locations contained within this sequence. - /// Complexity: `O(N)`. - func names() -> [String] { - return map { $0.name } - } - - /// Return the last location contained within this sequence. - /// Complexity: `O(N)`. - func last() -> Child? { - var iterator = Iterator( - folder: folder, - fileManager: fileManager, - isRecursive: isRecursive, - includeHidden: includeHidden, - reverseTopLevelTraversal: !isRecursive - ) - - guard isRecursive else { return iterator.next() } - - var child: Child? - - while let nextChild = iterator.next() { - child = nextChild - } - - return child - } - - /// Return the first location contained within this sequence. - /// Complexity: `O(1)`. - var first: Child? { - var iterator = makeIterator() - return iterator.next() - } - - /// Move all locations within this sequence to a new parent folder. - /// - parameter folder: The folder to move all locations to. - /// - throws: `LocationError` if the move couldn't be completed. - func move(to folder: Folder) throws { - try forEach { try $0.move(to: folder) } - } - - /// Delete all of the locations within this sequence. All items will - /// be permanently deleted. Use with caution. - /// - throws: `LocationError` if an item couldn't be deleted. Note that - /// all items deleted up to that point won't be recovered. - func delete() throws { - try forEach { try $0.delete() } - } -} - -public extension Folder { - static var kind: LocationKind { - return .folder - } - - /// The folder that the program is currently operating in. - static var current: Folder { - return try! Folder(path: "") - } - - /// The root folder of the file system. - static var root: Folder { - return try! Folder(path: "/") - } - - /// The current user's Home folder. - static var home: Folder { - return try! Folder(path: "~") - } - - /// The system's temporary folder. - static var temporary: Folder { - return try! Folder(path: NSTemporaryDirectory()) - } - - /// A sequence containing all of this folder's subfolders. Initially - /// non-recursive, use `recursive` on the returned sequence to change that. - var subfolders: ChildSequence { - return storage.makeChildSequence() - } - - /// A sequence containing all of this folder's files. Initially - /// non-recursive, use `recursive` on the returned sequence to change that. - var files: ChildSequence { - return storage.makeChildSequence() - } - - /// Return a subfolder at a given path within this folder. - /// - parameter path: A relative path within this folder. - /// - throws: `LocationError` if the subfolder couldn't be found. - func subfolder(at path: String) throws -> Folder { - return try storage.subfolder(at: path) - } - - /// Return a subfolder with a given name. - /// - parameter name: The name of the subfolder to return. - /// - throws: `LocationError` if the subfolder couldn't be found. - func subfolder(named name: String) throws -> Folder { - return try storage.subfolder(at: name) - } - - /// Return whether this folder contains a subfolder at a given path. - /// - parameter path: The relative path of the subfolder to look for. - func containsSubfolder(at path: String) -> Bool { - return (try? subfolder(at: path)) != nil - } - - /// Return whether this folder contains a subfolder with a given name. - /// - parameter name: The name of the subfolder to look for. - func containsSubfolder(named name: String) -> Bool { - return (try? subfolder(named: name)) != nil - } - - /// Create a new subfolder at a given path within this folder. In case - /// the intermediate folders between this folder and the new one don't - /// exist, those will be created as well. This method throws an error - /// if a folder already exists at the given path. - /// - parameter path: The relative path of the subfolder to create. - /// - throws: `WriteError` if the operation couldn't be completed. - @discardableResult - func createSubfolder(at path: String) throws -> Folder { - return try storage.createSubfolder(at: path) - } - - /// Create a new subfolder with a given name. This method throws an error - /// if a subfolder with the given name already exists. - /// - parameter name: The name of the subfolder to create. - /// - throws: `WriteError` if the operation couldn't be completed. - @discardableResult - func createSubfolder(named name: String) throws -> Folder { - return try storage.createSubfolder(at: name) - } - - /// Create a new subfolder at a given path within this folder. In case - /// the intermediate folders between this folder and the new one don't - /// exist, those will be created as well. If a folder already exists at - /// the given path, then it will be returned without modification. - /// - parameter path: The relative path of the subfolder. - /// - throws: `WriteError` if a new folder couldn't be created. - @discardableResult - func createSubfolderIfNeeded(at path: String) throws -> Folder { - return try (try? subfolder(at: path)) ?? createSubfolder(at: path) - } - - /// Create a new subfolder with a given name. If a subfolder with the given - /// name already exists, then it will be returned without modification. - /// - parameter name: The name of the subfolder. - /// - throws: `WriteError` if a new folder couldn't be created. - @discardableResult - func createSubfolderIfNeeded(withName name: String) throws -> Folder { - return try (try? subfolder(named: name)) ?? createSubfolder(named: name) - } - - /// Return a file at a given path within this folder. - /// - parameter path: A relative path within this folder. - /// - throws: `LocationError` if the file couldn't be found. - func file(at path: String) throws -> File { - return try storage.file(at: path) - } - - /// Return a file within this folder with a given name. - /// - parameter name: The name of the file to return. - /// - throws: `LocationError` if the file couldn't be found. - func file(named name: String) throws -> File { - return try storage.file(at: name) - } - - /// Return whether this folder contains a file at a given path. - /// - parameter path: The relative path of the file to look for. - func containsFile(at path: String) -> Bool { - return (try? file(at: path)) != nil - } - - /// Return whether this folder contains a file with a given name. - /// - parameter name: The name of the file to look for. - func containsFile(named name: String) -> Bool { - return (try? file(named: name)) != nil - } - - /// Create a new file at a given path within this folder. In case - /// the intermediate folders between this folder and the new file don't - /// exist, those will be created as well. This method throws an error - /// if a file already exists at the given path. - /// - parameter path: The relative path of the file to create. - /// - parameter contents: The initial `Data` that the file should contain. - /// - throws: `WriteError` if the operation couldn't be completed. - @discardableResult - func createFile(at path: String, contents: Data? = nil) throws -> File { - return try storage.createFile(at: path, contents: contents) - } - - /// Create a new file with a given name. This method throws an error - /// if a file with the given name already exists. - /// - parameter name: The name of the file to create. - /// - parameter contents: The initial `Data` that the file should contain. - /// - throws: `WriteError` if the operation couldn't be completed. - @discardableResult - func createFile(named fileName: String, contents: Data? = nil) throws -> File { - return try storage.createFile(at: fileName, contents: contents) - } - - /// Create a new file at a given path within this folder. In case - /// the intermediate folders between this folder and the new file don't - /// exist, those will be created as well. If a file already exists at - /// the given path, then it will be returned without modification. - /// - parameter path: The relative path of the file. - /// - parameter contents: The initial `Data` that any newly created file - /// should contain. Will only be evaluated if needed. - /// - throws: `WriteError` if a new file couldn't be created. - @discardableResult - func createFileIfNeeded(at path: String, - contents: @autoclosure () -> Data? = nil) throws -> File { - return try (try? file(at: path)) ?? createFile(at: path, contents: contents()) - } - - /// Create a new file with a given name. If a file with the given - /// name already exists, then it will be returned without modification. - /// - parameter name: The name of the file. - /// - parameter contents: The initial `Data` that any newly created file - /// should contain. Will only be evaluated if needed. - /// - throws: `WriteError` if a new file couldn't be created. - @discardableResult - func createFileIfNeeded(withName name: String, - contents: @autoclosure () -> Data? = nil) throws -> File { - return try (try? file(named: name)) ?? createFile(named: name, contents: contents()) - } - - /// Return whether this folder contains a given location as a direct child. - /// - parameter location: The location to find. - func contains(_ location: T) -> Bool { - switch T.kind { - case .file: return containsFile(named: location.name) - case .folder: return containsSubfolder(named: location.name) - } - } - - /// Move the contents of this folder to a new parent - /// - parameter folder: The new parent folder to move this folder's contents to. - /// - parameter includeHidden: Whether hidden files should be included (default: `false`). - /// - throws: `LocationError` if the operation couldn't be completed. - func moveContents(to folder: Folder, includeHidden: Bool = false) throws { - var files = self.files - files.includeHidden = includeHidden - try files.move(to: folder) - - var folders = subfolders - folders.includeHidden = includeHidden - try folders.move(to: folder) - } - - /// Empty this folder, permanently deleting all of its contents. Use with caution. - /// - parameter includeHidden: Whether hidden files should also be deleted (default: `false`). - /// - throws: `LocationError` if the operation couldn't be completed. - func empty(includingHidden includeHidden: Bool = false) throws { - var files = self.files - files.includeHidden = includeHidden - try files.delete() - - var folders = subfolders - folders.includeHidden = includeHidden - try folders.delete() - } - - func isEmpty(includingHidden includeHidden: Bool = false) -> Bool { - var files = self.files - files.includeHidden = includeHidden - - if files.first != nil { - return false - } - - var folders = subfolders - folders.includeHidden = includeHidden - return folders.first == nil - } -} - -#if os(iOS) || os(tvOS) || os(macOS) -public extension Folder { - /// Resolve a folder that matches a search path within a given domain. - /// - parameter searchPath: The directory path to search for. - /// - parameter domain: The domain to search in. - /// - parameter fileManager: Which file manager to search using. - /// - throws: `LocationError` if no folder could be resolved. - static func matching( - _ searchPath: FileManager.SearchPathDirectory, - in domain: FileManager.SearchPathDomainMask = .userDomainMask, - resolvedBy fileManager: FileManager = .default - ) throws -> Folder { - let urls = fileManager.urls(for: searchPath, in: domain) - - guard let match = urls.first else { - throw LocationError( - path: "", - reason: .unresolvedSearchPath(searchPath, domain: domain) - ) - } - - return try Folder(storage: Storage( - path: match.relativePath, - fileManager: fileManager - )) - } - - /// The current user's Documents folder - static var documents: Folder? { - return try? .matching(.documentDirectory) - } - - /// The current user's Library folder - static var library: Folder? { - return try? .matching(.libraryDirectory) - } -} -#endif - -// MARK: - Errors - -/// Error type thrown by all of Files' throwing APIs. -public struct FilesError: Error { - /// The absolute path that the error occured at. - public var path: String - /// The reason that the error occured. - public var reason: Reason - - /// Initialize an instance with a path and a reason. - /// - parameter path: The absolute path that the error occured at. - /// - parameter reason: The reason that the error occured. - public init(path: String, reason: Reason) { - self.path = path - self.reason = reason - } -} - -extension FilesError: CustomStringConvertible { - public var description: String { - return """ - Files encountered an error at '\(path)'. - Reason: \(reason) - """ - } -} - -/// Enum listing reasons that a location manipulation could fail. -public enum LocationErrorReason { - /// The location couldn't be found. - case missing - /// An empty path was given when refering to a file. - case emptyFilePath - /// The user attempted to rename the file system's root folder. - case cannotRenameRoot - /// A rename operation failed with an underlying system error. - case renameFailed(Error) - /// A move operation failed with an underlying system error. - case moveFailed(Error) - /// A copy operation failed with an underlying system error. - case copyFailed(Error) - /// A delete operation failed with an underlying system error. - case deleteFailed(Error) - /// A search path couldn't be resolved within a given domain. - case unresolvedSearchPath( - FileManager.SearchPathDirectory, - domain: FileManager.SearchPathDomainMask - ) -} - -/// Enum listing reasons that a write operation could fail. -public enum WriteErrorReason { - /// An empty path was given when writing or creating a location. - case emptyPath - /// A folder couldn't be created because of an underlying system error. - case folderCreationFailed(Error) - /// A file couldn't be created. - case fileCreationFailed - /// A file couldn't be written to because of an underlying system error. - case writeFailed(Error) - /// Failed to encode a string into binary data. - case stringEncodingFailed(String) -} - -/// Enum listing reasons that a read operation could fail. -public enum ReadErrorReason { - /// A file couldn't be read because of an underlying system error. - case readFailed(Error) - /// Failed to decode a given set of data into a string. - case stringDecodingFailed - /// Encountered a string that doesn't contain an integer. - case notAnInt(String) -} - -/// Error thrown by location operations - such as find, move, copy and delete. -public typealias LocationError = FilesError -/// Error thrown by write operations - such as file/folder creation. -public typealias WriteError = FilesError -/// Error thrown by read operations - such as when reading a file's contents. -public typealias ReadError = FilesError - -// MARK: - Private system extensions - -private extension FileManager { - func locationExists(at path: String, kind: LocationKind) -> Bool { - var isFolder: ObjCBool = false - - guard fileExists(atPath: path, isDirectory: &isFolder) else { - return false - } - - switch kind { - case .file: return !isFolder.boolValue - case .folder: return isFolder.boolValue - } - } -} - -private extension String { - func removingPrefix(_ prefix: String) -> String { - guard hasPrefix(prefix) else { return self } - return String(dropFirst(prefix.count)) - } - - func removingSuffix(_ suffix: String) -> String { - guard hasSuffix(suffix) else { return self } - return String(dropLast(suffix.count)) - } - - func appendingSuffixIfNeeded(_ suffix: String) -> String { - guard !hasSuffix(suffix) else { return self } - return appending(suffix) - } -} diff --git a/Sources/FilesError.swift b/Sources/FilesError.swift new file mode 100644 index 0000000..cead8d3 --- /dev/null +++ b/Sources/FilesError.swift @@ -0,0 +1,58 @@ +// +// FilesError.swift +// +// Copyright (c) 2017-2019 John Sundell. +// Licensed under the MIT license, as follows: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +/// Error thrown by location operations - such as find, move, copy and delete. +public typealias LocationError = FilesError +/// Error thrown by write operations - such as file/folder creation. +public typealias WriteError = FilesError +/// Error thrown by read operations - such as when reading a file's contents. +public typealias ReadError = FilesError + +/// Error type thrown by all of Files' throwing APIs. +public struct FilesError: Error { + /// The absolute path that the error occured at. + public var path: String + /// The reason that the error occured. + public var reason: Reason + + /// Initialize an instance with a path and a reason. + /// - Parameters: + /// - path: The absolute path that the error occured at. + /// - reason: The reason that the error occured. + public init(path: String, reason: Reason) { + self.path = path + self.reason = reason + } +} + +extension FilesError: CustomStringConvertible { + /// A textual representation of the error, including its path and reason. + public var description: String { + """ + Files encountered an error at '\(path)'. + Reason: \(reason) + """ + } +} diff --git a/Sources/Folder+Children.swift b/Sources/Folder+Children.swift new file mode 100644 index 0000000..ad79228 --- /dev/null +++ b/Sources/Folder+Children.swift @@ -0,0 +1,147 @@ +// +// Folder+Children.swift +// +// Copyright (c) 2017-2019 John Sundell. +// Licensed under the MIT license, as follows: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +extension Folder { + /// A sequence of child locations contained within a given folder. + /// You obtain an instance of this type by accessing either `files` + /// or `subfolders` on a `Folder` instance. + public struct ChildSequence: Sequence { + internal let folder: Folder + internal let fileManager: FileManager + internal var isRecursive: Bool + internal var includeHidden: Bool + + /// Create an iterator that traverses this sequence's child locations. + /// - returns: A new iterator over the sequence's contents. + public func makeIterator() -> ChildIterator { + ChildIterator( + folder: folder, + fileManager: fileManager, + isRecursive: isRecursive, + includeHidden: includeHidden, + reverseTopLevelTraversal: false + ) + } + } + + /// The type of iterator used by `ChildSequence`. You don't interact + /// with this type directly. See `ChildSequence` for more information. + public struct ChildIterator: IteratorProtocol { + private let folder: Folder + private let fileManager: FileManager + private let isRecursive: Bool + private let includeHidden: Bool + private let reverseTopLevelTraversal: Bool + private lazy var itemNames = loadItemNames() + private var index = 0 + private var nestedIterators = [ChildIterator]() + + internal init( + folder: Folder, + fileManager: FileManager, + isRecursive: Bool, + includeHidden: Bool, + reverseTopLevelTraversal: Bool + ) { + self.folder = folder + self.fileManager = fileManager + self.isRecursive = isRecursive + self.includeHidden = includeHidden + self.reverseTopLevelTraversal = reverseTopLevelTraversal + } + + // swiftlint:disable cyclomatic_complexity + /// Advance to and return the next child location, or `nil` when the + /// sequence has been exhausted. + /// - returns: The next child location, or `nil` if there are no more. + public mutating func next() -> Child? { + guard index < itemNames.count else { + guard var nested = nestedIterators.first else { + return nil + } + + guard let child = nested.next() else { + nestedIterators.removeFirst() + return next() + } + + nestedIterators[0] = nested + return child + } + + let name = itemNames[index] + index += 1 + + if !includeHidden { + guard !name.hasPrefix(".") else { + return next() + } + } + + let childPath = folder.path + name.removingPrefix("/") + let childStorage = try? Storage(path: childPath) + let child = childStorage.map(Child.init) + + if isRecursive { + let childFolder = + (child as? Folder) + ?? (try? Folder( + storage: Storage(path: childPath) + )) + + if let childFolder = childFolder { + let nested = ChildIterator( + folder: childFolder, + fileManager: fileManager, + isRecursive: true, + includeHidden: includeHidden, + reverseTopLevelTraversal: false + ) + + nestedIterators.append(nested) + } + } + + return child ?? next() + } + // swiftlint:enable cyclomatic_complexity + + private mutating func loadItemNames() -> [String] { + let contents = try? fileManager.contentsOfDirectory(atPath: folder.path.nativePath) + let names = contents?.sorted() ?? [] + return reverseTopLevelTraversal ? names.reversed() : names + } + } +} + +extension Folder.ChildSequence: CustomStringConvertible { + /// A textual representation of the sequence, listing each child's + /// description on its own line. + public var description: String { + lazy.map({ $0.description }).joined(separator: "\n") + } +} diff --git a/Sources/Folder+Contents.swift b/Sources/Folder+Contents.swift new file mode 100644 index 0000000..449bd37 --- /dev/null +++ b/Sources/Folder+Contents.swift @@ -0,0 +1,80 @@ +// +// Folder+Contents.swift +// +// Copyright (c) 2017-2019 John Sundell. +// Licensed under the MIT license, as follows: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +extension Folder { + /// Return whether this folder contains a given location as a direct child. + /// - parameter location: The location to find. + /// - returns: `true` if the location is a direct child of this folder. + public func contains(_ location: T) -> Bool { + switch T.kind { + case .file: return containsFile(named: location.name) + case .folder: return containsSubfolder(named: location.name) + } + } + + /// Move the contents of this folder to a new parent + /// - Parameters: + /// - folder: The new parent folder to move this folder's contents to. + /// - includeHidden: Whether hidden files should be included (default: `false`). + /// - throws: `LocationError` if the operation couldn't be completed. + public func moveContents(to folder: Folder, includeHidden: Bool = false) throws(LocationError) { + var files = self.files + files.includeHidden = includeHidden + try files.move(to: folder) + + var folders = subfolders + folders.includeHidden = includeHidden + try folders.move(to: folder) + } + + /// Empty this folder, permanently deleting all of its contents. Use with caution. + /// - parameter includeHidden: Whether hidden files should also be deleted (default: `false`). + /// - throws: `LocationError` if the operation couldn't be completed. + public func empty(includingHidden includeHidden: Bool = false) throws(LocationError) { + var files = self.files + files.includeHidden = includeHidden + try files.delete() + + var folders = subfolders + folders.includeHidden = includeHidden + try folders.delete() + } + + /// Return whether this folder doesn't contain any locations. + /// - parameter includeHidden: Whether hidden files should be considered (default: `false`). + /// - returns: `true` if the folder contains no matching locations. + public func isEmpty(includingHidden includeHidden: Bool = false) -> Bool { + var files = self.files + files.includeHidden = includeHidden + + if files.first != nil { + return false + } + + var folders = subfolders + folders.includeHidden = includeHidden + return folders.first == nil + } +} diff --git a/Sources/Folder+Files.swift b/Sources/Folder+Files.swift new file mode 100644 index 0000000..9ac8098 --- /dev/null +++ b/Sources/Folder+Files.swift @@ -0,0 +1,124 @@ +// +// Folder+Files.swift +// +// Copyright (c) 2017-2019 John Sundell. +// Licensed under the MIT license, as follows: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +extension Folder { + /// Return a file at a given path within this folder. + /// - parameter path: A relative path within this folder. + /// - throws: `LocationError` if the file couldn't be found. + /// - returns: The file located at the given path. + public func file(at path: String) throws(LocationError) -> File { + try storage.file(at: path) + } + + /// Return a file within this folder with a given name. + /// - parameter name: The name of the file to return. + /// - throws: `LocationError` if the file couldn't be found. + /// - returns: The file with the given name. + public func file(named name: String) throws(LocationError) -> File { + try storage.file(at: name) + } + + /// Return whether this folder contains a file at a given path. + /// - parameter path: The relative path of the file to look for. + /// - returns: `true` if a file exists at the given path. + public func containsFile(at path: String) -> Bool { + (try? file(at: path)) != nil + } + + /// Return whether this folder contains a file with a given name. + /// - parameter name: The name of the file to look for. + /// - returns: `true` if a file with the given name exists. + public func containsFile(named name: String) -> Bool { + (try? file(named: name)) != nil + } + + /// Create a new file at a given path within this folder. In case + /// the intermediate folders between this folder and the new file don't + /// exist, those will be created as well. This method throws an error + /// if a file already exists at the given path. + /// - Parameters: + /// - path: The relative path of the file to create. + /// - contents: The initial `Data` that the file should contain. + /// - throws: `WriteError` if the operation couldn't be completed. + /// - returns: The newly created file. + @discardableResult + public func createFile(at path: String, contents: Data? = nil) throws(WriteError) -> File { + try storage.createFile(at: path, contents: contents) + } + + /// Create a new file with a given name. This method throws an error + /// if a file with the given name already exists. + /// - Parameters: + /// - fileName: The name of the file to create. + /// - contents: The initial `Data` that the file should contain. + /// - throws: `WriteError` if the operation couldn't be completed. + /// - returns: The newly created file. + @discardableResult + public func createFile(named fileName: String, contents: Data? = nil) throws(WriteError) -> File { + try storage.createFile(at: fileName, contents: contents) + } + + /// Create a new file at a given path within this folder. In case + /// the intermediate folders between this folder and the new file don't + /// exist, those will be created as well. If a file already exists at + /// the given path, then it will be returned without modification. + /// - Parameters: + /// - path: The relative path of the file. + /// - contents: The initial `Data` that any newly created file + /// should contain. Will only be evaluated if needed. + /// - throws: `WriteError` if a new file couldn't be created. + /// - returns: The existing or newly created file. + @discardableResult + public func createFileIfNeeded( + at path: String, + contents: @autoclosure () -> Data? = nil + ) throws(WriteError) -> File { + if let existing = try? file(at: path) { + return existing + } + return try createFile(at: path, contents: contents()) + } + + /// Create a new file with a given name. If a file with the given + /// name already exists, then it will be returned without modification. + /// - Parameters: + /// - name: The name of the file. + /// - contents: The initial `Data` that any newly created file + /// should contain. Will only be evaluated if needed. + /// - throws: `WriteError` if a new file couldn't be created. + /// - returns: The existing or newly created file. + @discardableResult + public func createFileIfNeeded( + withName name: String, + contents: @autoclosure () -> Data? = nil + ) throws(WriteError) -> File { + if let existing = try? file(named: name) { + return existing + } + return try createFile(named: name, contents: contents()) + } +} diff --git a/Sources/Folder+SearchPath.swift b/Sources/Folder+SearchPath.swift new file mode 100644 index 0000000..f12c7cc --- /dev/null +++ b/Sources/Folder+SearchPath.swift @@ -0,0 +1,64 @@ +// +// Folder+SearchPath.swift +// +// Copyright (c) 2017-2019 John Sundell. +// Licensed under the MIT license, as follows: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +#if os(iOS) || os(tvOS) || os(macOS) + extension Folder { + /// The current user's Documents folder + public static var documents: Folder? { + try? .matching(.documentDirectory) + } + + /// The current user's Library folder + public static var library: Folder? { + try? .matching(.libraryDirectory) + } + + /// Resolve a folder that matches a search path within a given domain. + /// - Parameters: + /// - searchPath: The directory path to search for. + /// - domain: The domain to search in. + /// - fileManager: Which file manager to search using. + /// - throws: `LocationError` if no folder could be resolved. + /// - returns: The resolved folder for the given search path. + public static func matching( + _ searchPath: FileManager.SearchPathDirectory, + in domain: FileManager.SearchPathDomainMask = .userDomainMask, + resolvedBy fileManager: FileManager = .default + ) throws -> Folder { + let urls = fileManager.urls(for: searchPath, in: domain) + + guard let match = urls.first else { + throw LocationError( + path: "", + reason: .unresolvedSearchPath(searchPath, domain: domain) + ) + } + + return try Folder(storage: Storage(path: match.relativePath)) + } + } +#endif diff --git a/Sources/Folder+Subfolders.swift b/Sources/Folder+Subfolders.swift new file mode 100644 index 0000000..49004f9 --- /dev/null +++ b/Sources/Folder+Subfolders.swift @@ -0,0 +1,106 @@ +// +// Folder+Subfolders.swift +// +// Copyright (c) 2017-2019 John Sundell. +// Licensed under the MIT license, as follows: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +extension Folder { + /// Return a subfolder at a given path within this folder. + /// - parameter path: A relative path within this folder. + /// - throws: `LocationError` if the subfolder couldn't be found. + /// - returns: The subfolder located at the given path. + public func subfolder(at path: String) throws(LocationError) -> Folder { + try storage.subfolder(at: path) + } + + /// Return a subfolder with a given name. + /// - parameter name: The name of the subfolder to return. + /// - throws: `LocationError` if the subfolder couldn't be found. + /// - returns: The subfolder with the given name. + public func subfolder(named name: String) throws(LocationError) -> Folder { + try storage.subfolder(at: name) + } + + /// Return whether this folder contains a subfolder at a given path. + /// - parameter path: The relative path of the subfolder to look for. + /// - returns: `true` if a subfolder exists at the given path. + public func containsSubfolder(at path: String) -> Bool { + (try? subfolder(at: path)) != nil + } + + /// Return whether this folder contains a subfolder with a given name. + /// - parameter name: The name of the subfolder to look for. + /// - returns: `true` if a subfolder with the given name exists. + public func containsSubfolder(named name: String) -> Bool { + (try? subfolder(named: name)) != nil + } + + /// Create a new subfolder at a given path within this folder. In case + /// the intermediate folders between this folder and the new one don't + /// exist, those will be created as well. This method throws an error + /// if a folder already exists at the given path. + /// - parameter path: The relative path of the subfolder to create. + /// - throws: `WriteError` if the operation couldn't be completed. + /// - returns: The newly created subfolder. + @discardableResult + public func createSubfolder(at path: String) throws(WriteError) -> Folder { + try storage.createSubfolder(at: path) + } + + /// Create a new subfolder with a given name. This method throws an error + /// if a subfolder with the given name already exists. + /// - parameter name: The name of the subfolder to create. + /// - throws: `WriteError` if the operation couldn't be completed. + /// - returns: The newly created subfolder. + @discardableResult + public func createSubfolder(named name: String) throws(WriteError) -> Folder { + try storage.createSubfolder(at: name) + } + + /// Create a new subfolder at a given path within this folder. In case + /// the intermediate folders between this folder and the new one don't + /// exist, those will be created as well. If a folder already exists at + /// the given path, then it will be returned without modification. + /// - parameter path: The relative path of the subfolder. + /// - throws: `WriteError` if a new folder couldn't be created. + /// - returns: The existing or newly created subfolder. + @discardableResult + public func createSubfolderIfNeeded(at path: String) throws(WriteError) -> Folder { + if let existing = try? subfolder(at: path) { + return existing + } + return try createSubfolder(at: path) + } + + /// Create a new subfolder with a given name. If a subfolder with the given + /// name already exists, then it will be returned without modification. + /// - parameter name: The name of the subfolder. + /// - throws: `WriteError` if a new folder couldn't be created. + /// - returns: The existing or newly created subfolder. + @discardableResult + public func createSubfolderIfNeeded(withName name: String) throws(WriteError) -> Folder { + if let existing = try? subfolder(named: name) { + return existing + } + return try createSubfolder(named: name) + } +} diff --git a/Sources/Folder.ChildSequence.swift b/Sources/Folder.ChildSequence.swift new file mode 100644 index 0000000..d3ec763 --- /dev/null +++ b/Sources/Folder.ChildSequence.swift @@ -0,0 +1,103 @@ +// +// Folder.ChildSequence.swift +// +// Copyright (c) 2017-2019 John Sundell. +// Licensed under the MIT license, as follows: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +extension Folder.ChildSequence { + /// Return a new instance of this sequence that'll traverse the folder's + /// contents recursively, in a breadth-first manner. Complexity: `O(1)`. + public var recursive: Folder.ChildSequence { + var sequence = self + sequence.isRecursive = true + return sequence + } + + /// Return a new instance of this sequence that'll include all hidden + /// (dot) files when traversing the folder's contents. Complexity: `O(1)`. + public var includingHidden: Folder.ChildSequence { + var sequence = self + sequence.includeHidden = true + return sequence + } + + /// Return the first location contained within this sequence. + /// Complexity: `O(1)`. + public var first: Child? { + var iterator = makeIterator() + return iterator.next() + } + + /// Count the number of locations contained within this sequence. + /// Complexity: `O(N)`. + /// - returns: The number of locations in the sequence. + public func count() -> Int { + reduce(0) { count, _ in count + 1 } + } + + /// Gather the names of all of the locations contained within this sequence. + /// Complexity: `O(N)`. + /// - returns: The names of the locations in the sequence. + public func names() -> [String] { + map { $0.name } + } + + /// Return the last location contained within this sequence. + /// Complexity: `O(N)`. + /// - returns: The last location, or `nil` if the sequence is empty. + public func last() -> Child? { + var iterator = Iterator( + folder: folder, + fileManager: fileManager, + isRecursive: isRecursive, + includeHidden: includeHidden, + reverseTopLevelTraversal: !isRecursive + ) + + guard isRecursive else { + return iterator.next() + } + + var child: Child? + + while let nextChild = iterator.next() { + child = nextChild + } + + return child + } + + /// Move all locations within this sequence to a new parent folder. + /// - parameter folder: The folder to move all locations to. + /// - throws: `LocationError` if the move couldn't be completed. + public func move(to folder: Folder) throws(LocationError) { + for child in self { try child.move(to: folder) } + } + + /// Delete all of the locations within this sequence. All items will + /// be permanently deleted. Use with caution. + /// - throws: `LocationError` if an item couldn't be deleted. Note that + /// all items deleted up to that point won't be recovered. + public func delete() throws(LocationError) { + for child in self { try child.delete() } + } +} diff --git a/Sources/Folder.swift b/Sources/Folder.swift new file mode 100644 index 0000000..3566a9e --- /dev/null +++ b/Sources/Folder.swift @@ -0,0 +1,94 @@ +// +// Folder.swift +// +// Copyright (c) 2017-2019 John Sundell. +// Licensed under the MIT license, as follows: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +/// Type that represents a folder on disk. You can either reference an existing +/// folder by initializing an instance with a `path`, or you can create new +/// subfolders using this type's various `createSubfolder...` APIs. +public struct Folder: Location { + /// The underlying storage for the item at the represented location. + public let storage: Storage + + /// Initialize an instance of this folder with its underlying storage. + /// - parameter storage: The storage backing this folder. + public init(storage: Storage) { + self.storage = storage + } +} + +extension Folder { + /// The kind of location that is being represented. + public static var kind: LocationKind { + .folder + } + + // swift-format-ignore: NeverUseForceTry + /// The folder that the program is currently operating in. + public static var current: Folder { + // The current working directory always resolves to an existing folder. + // swiftlint:disable:next force_try + try! Folder(path: "") + } + + // swift-format-ignore: NeverUseForceTry + /// The root folder of the file system. + /// + /// On Windows this is the root of the current working volume (e.g. `C:/`), + /// because Windows has no single filesystem root; elsewhere it is `/`. + public static var root: Folder { + // The file system root always exists. + // swiftlint:disable:next force_try + try! Folder(path: Path.rootPath) + } + + // swift-format-ignore: NeverUseForceTry + /// The current user's Home folder. + public static var home: Folder { + // A process always has a resolvable home folder. + // swiftlint:disable:next force_try + try! Folder(path: "~") + } + + // swift-format-ignore: NeverUseForceTry + /// The system's temporary folder. + public static var temporary: Folder { + // The system temporary folder always exists. + // swiftlint:disable:next force_try + try! Folder(path: NSTemporaryDirectory()) + } + + /// A sequence containing all of this folder's subfolders. Initially + /// non-recursive, use `recursive` on the returned sequence to change that. + public var subfolders: ChildSequence { + storage.makeChildSequence() + } + + /// A sequence containing all of this folder's files. Initially + /// non-recursive, use `recursive` on the returned sequence to change that. + public var files: ChildSequence { + storage.makeChildSequence() + } +} diff --git a/Sources/Location.swift b/Sources/Location.swift new file mode 100644 index 0000000..8133a25 --- /dev/null +++ b/Sources/Location.swift @@ -0,0 +1,192 @@ +// +// Location.swift +// +// Copyright (c) 2017-2019 John Sundell. +// Licensed under the MIT license, as follows: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +/// Protocol adopted by types that represent locations on a file system. +public protocol Location: Equatable, CustomStringConvertible, Sendable { + /// The kind of location that is being represented (see `LocationKind`). + static var kind: LocationKind { get } + /// The underlying storage for the item at the represented location. + /// You don't interact with this object as part of the public API. + var storage: Storage { get } + /// Initialize an instance of this location with its underlying storage. + /// You don't call this initializer as part of the public API, instead + /// use `init(path:)` on either `File` or `Folder`. + init(storage: Storage) +} + +extension Location { + /// A textual representation of the location, including its name and path. + public var description: String { + let typeName = String(describing: type(of: self)) + return "\(typeName)(name: \(name), path: \(path))" + } + + /// The path of this location, relative to the root of the file system. + public var path: String { + storage.path + } + + /// A URL representation of the location's `path`. + public var url: URL { + URL(fileURLWithPath: path.nativePath) + } + + // swift-format-ignore: NeverForceUnwrap + /// The name of the location, including any `extension`. + public var name: String { + // `pathComponents` always contains at least the root ("/"), so its + // `last` element is never `nil` for a file URL. + // swiftlint:disable:next force_unwrapping + url.pathComponents.last! + } + + /// The name of the location, excluding its `extension`. + public var nameExcludingExtension: String { + let components = name.split(separator: ".") + guard components.count > 1 else { + return name + } + return components.dropLast().joined() + } + + /// The file extension of the item at the location. + public var `extension`: String? { + let components = name.split(separator: ".") + guard components.count > 1, let last = components.last else { + return nil + } + return String(last) + } + + /// The parent folder that this location is contained within. + public var parent: Folder? { + makeParentPath(for: path).flatMap { + try? Folder(path: $0) + } + } + + /// The date when the item at this location was created. + /// Only returns `nil` in case the item has now been deleted. + public var creationDate: Date? { + storage.attributes[.creationDate] as? Date + } + + /// The date when the item at this location was last modified. + /// Only returns `nil` in case the item has now been deleted. + public var modificationDate: Date? { + storage.attributes[.modificationDate] as? Date + } + + /// Initialize an instance of an existing location at a given path. + /// - parameter path: The absolute path of the location. + /// - throws: `LocationError` if the item couldn't be found. + public init(path: String) throws(LocationError) { + try self.init(storage: Storage(path: path)) + } + + /// Compare two locations for equality based on their paths. + /// + /// Paths are stored in canonical (forward-slash) form, so this comparison is + /// separator-independent. It is case-sensitive on every platform (matching the + /// library's original behavior); Windows/macOS filesystem case-insensitivity is + /// intentionally not reflected here. + /// - Parameters: + /// - lhs: The first location to compare. + /// - rhs: The second location to compare. + /// - returns: `true` if both locations refer to the same path. + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.storage.path == rhs.storage.path + } + + /// Return the path of this location relative to a parent folder. + /// For example, if this item is located at `/users/john/documents` + /// and `/users/john` is passed, then `documents` is returned. If the + /// passed folder isn't an ancestor of this item, then the item's + /// absolute `path` is returned instead. + /// - parameter folder: The folder to compare this item's path against. + /// - returns: The path relative to the given folder. + public func path(relativeTo folder: Folder) -> String { + guard path.hasPrefix(folder.path) else { + return path + } + + let index = path.index(path.startIndex, offsetBy: folder.path.count) + return String(path[index...]).removingSuffix("/") + } + + /// Rename this location, keeping its existing `extension` by default. + /// - Parameters: + /// - newName: The new name to give the location. + /// - keepExtension: Whether the location's `extension` should + /// remain unmodified (default: `true`). + /// - throws: `LocationError` if the item couldn't be renamed. + public func rename(to newName: String, keepExtension: Bool = true) throws(LocationError) { + guard let parent = parent else { + throw LocationError(path: path, reason: .cannotRenameRoot) + } + + var newName = newName + + if keepExtension { + `extension`.map { + newName = newName.appendingSuffixIfNeeded(".\($0)") + } + } + + try storage.move( + to: parent.path + newName, + errorReasonProvider: LocationErrorReason.renameFailed + ) + } + + /// Move this location to a new parent folder + /// - parameter newParent: The folder to move this item to. + /// - throws: `LocationError` if the location couldn't be moved. + public func move(to newParent: Folder) throws(LocationError) { + try storage.move( + to: newParent.path + name, + errorReasonProvider: LocationErrorReason.moveFailed + ) + } + + /// Copy the contents of this location to a given folder + /// - parameter folder: The folder to copy this item to. + /// - throws: `LocationError` if the location couldn't be copied. + /// - returns: The new, copied location. + @discardableResult + public func copy(to folder: Folder) throws(LocationError) -> Self { + let path = folder.path + name + try storage.copy(to: path) + return try Self(path: path) + } + + /// Delete this location. It will be permanently deleted. Use with caution. + /// - throws: `LocationError` if the item couldn't be deleted. + public func delete() throws(LocationError) { + try storage.delete() + } +} diff --git a/Sources/LocationErrorReason.swift b/Sources/LocationErrorReason.swift new file mode 100644 index 0000000..44eacef --- /dev/null +++ b/Sources/LocationErrorReason.swift @@ -0,0 +1,49 @@ +// +// LocationErrorReason.swift +// +// Copyright (c) 2017-2019 John Sundell. +// Licensed under the MIT license, as follows: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +/// Enum listing reasons that a location manipulation could fail. +public enum LocationErrorReason: Sendable { + /// The location couldn't be found. + case missing + /// An empty path was given when refering to a file. + case emptyFilePath + /// The user attempted to rename the file system's root folder. + case cannotRenameRoot + /// A rename operation failed with an underlying system error. + case renameFailed(any Error & Sendable) + /// A move operation failed with an underlying system error. + case moveFailed(any Error & Sendable) + /// A copy operation failed with an underlying system error. + case copyFailed(any Error & Sendable) + /// A delete operation failed with an underlying system error. + case deleteFailed(any Error & Sendable) + /// A search path couldn't be resolved within a given domain. + case unresolvedSearchPath( + FileManager.SearchPathDirectory, + domain: FileManager.SearchPathDomainMask + ) +} diff --git a/Sources/LocationKind.swift b/Sources/LocationKind.swift new file mode 100644 index 0000000..6c08cdc --- /dev/null +++ b/Sources/LocationKind.swift @@ -0,0 +1,32 @@ +// +// LocationKind.swift +// +// Copyright (c) 2017-2019 John Sundell. +// Licensed under the MIT license, as follows: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +/// Enum describing various kinds of locations that can be found on a file system. +public enum LocationKind: Sendable { + /// A file can be found at the location. + case file + /// A folder can be found at the location. + case folder +} diff --git a/Sources/Path.swift b/Sources/Path.swift new file mode 100644 index 0000000..8cd8818 --- /dev/null +++ b/Sources/Path.swift @@ -0,0 +1,101 @@ +// +// Path.swift +// +// Copyright (c) 2017-2019 John Sundell. +// Licensed under the MIT license, as follows: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +/// Platform path helpers. +/// +/// The library stores every path in a single canonical form that always uses the +/// forward slash (`/`) as its separator, regardless of platform. All of the +/// library's internal path logic (the folder trailing-slash invariant, `../` +/// resolution, parent derivation, child concatenation, and `==`) operates on that +/// canonical form. Native separators (`\` on Windows) are used only when a path is +/// handed to `FileManager` or `URL(fileURLWithPath:)`, via `nativePath`. +/// +/// On every non-Windows platform each helper is the identity, so behavior there is +/// unchanged. +internal enum Path { + /// The platform's native path separator (`\` on Windows, `/` elsewhere). + #if os(Windows) + internal static let nativeSeparator = "\\" + #else + internal static let nativeSeparator = "/" + #endif + + /// The canonical root of the file system. + /// + /// On Windows this is the root of the current working volume (for example + /// `C:/`), because Windows has no single filesystem root. Elsewhere it is `/`. + internal static var rootPath: String { + #if os(Windows) + let cwd = FileManager.default.currentDirectoryPath.canonicalizedPath + // A canonical Windows path starts with a drive letter, e.g. "C:/Users/…". + if let colon = cwd.firstIndex(of: ":") { + return String(cwd[...colon]) + "/" + } + return "/" + #else + return "/" + #endif + } +} + +extension String { + /// The path in the library's canonical (forward-slash) form. + /// + /// On Windows this rewrites native `\` separators to `/`; elsewhere it returns + /// the string unchanged. + internal var canonicalizedPath: String { + #if os(Windows) + return replacingOccurrences(of: "\\", with: "/") + #else + return self + #endif + } + + /// The path in the platform's native form, suitable for `FileManager` and + /// `URL(fileURLWithPath:)`. + /// + /// On Windows this rewrites canonical `/` separators to `\`; elsewhere it + /// returns the string unchanged. + internal var nativePath: String { + #if os(Windows) + return replacingOccurrences(of: "/", with: "\\") + #else + return self + #endif + } + + /// Whether this canonical path represents a Windows volume root (`C:/` or `C:`). + /// + /// Always `false` on non-Windows platforms, where the sole root is `/`. + internal var isDriveRoot: Bool { + #if os(Windows) + return count <= 3 && (hasSuffix(":/") || hasSuffix(":")) + #else + return false + #endif + } +} diff --git a/Sources/ReadErrorReason.swift b/Sources/ReadErrorReason.swift new file mode 100644 index 0000000..aea1d04 --- /dev/null +++ b/Sources/ReadErrorReason.swift @@ -0,0 +1,34 @@ +// +// ReadErrorReason.swift +// +// Copyright (c) 2017-2019 John Sundell. +// Licensed under the MIT license, as follows: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +/// Enum listing reasons that a read operation could fail. +public enum ReadErrorReason: Sendable { + /// A file couldn't be read because of an underlying system error. + case readFailed(any Error & Sendable) + /// Failed to decode a given set of data into a string. + case stringDecodingFailed + /// Encountered a string that doesn't contain an integer. + case notAnInt(String) +} diff --git a/Sources/Storage+Folder.swift b/Sources/Storage+Folder.swift new file mode 100644 index 0000000..b8e8f64 --- /dev/null +++ b/Sources/Storage+Folder.swift @@ -0,0 +1,96 @@ +// +// Storage+Folder.swift +// +// Copyright (c) 2017-2019 John Sundell. +// Licensed under the MIT license, as follows: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +extension Storage where LocationType == Folder { + internal func makeChildSequence() -> Folder.ChildSequence { + Folder.ChildSequence( + folder: Folder(storage: self), + fileManager: .default, + isRecursive: false, + includeHidden: false + ) + } + + internal func subfolder(at folderPath: String) throws(LocationError) -> Folder { + let folderPath = path + folderPath.canonicalizedPath.removingPrefix("/") + let storage = try Storage(path: folderPath) + return Folder(storage: storage) + } + + internal func file(at filePath: String) throws(LocationError) -> File { + let filePath = path + filePath.canonicalizedPath.removingPrefix("/") + let storage = try Storage(path: filePath) + return File(storage: storage) + } + + internal func createSubfolder(at folderPath: String) throws(WriteError) -> Folder { + let folderPath = path + folderPath.canonicalizedPath.removingPrefix("/") + + guard folderPath != path else { + throw WriteError(path: folderPath, reason: .emptyPath) + } + + do { + try FileManager.default.createDirectory( + atPath: folderPath.nativePath, + withIntermediateDirectories: true + ) + + let storage = try Storage(path: folderPath) + return Folder(storage: storage) + } catch { + throw WriteError(path: folderPath, reason: .folderCreationFailed(error)) + } + } + + internal func createFile(at filePath: String, contents: Data?) throws(WriteError) -> File { + let filePath = path + filePath.canonicalizedPath.removingPrefix("/") + + guard let parentPath = makeParentPath(for: filePath) else { + throw WriteError(path: filePath, reason: .emptyPath) + } + + if parentPath != path { + do { + try FileManager.default.createDirectory( + atPath: parentPath.nativePath, + withIntermediateDirectories: true + ) + } catch { + throw WriteError(path: parentPath, reason: .folderCreationFailed(error)) + } + } + + guard FileManager.default.createFile(atPath: filePath.nativePath, contents: contents), + let storage = try? Storage(path: filePath) + else { + throw WriteError(path: filePath, reason: .fileCreationFailed) + } + + return File(storage: storage) + } +} diff --git a/Sources/Storage.swift b/Sources/Storage.swift new file mode 100644 index 0000000..4597802 --- /dev/null +++ b/Sources/Storage.swift @@ -0,0 +1,174 @@ +// +// Storage.swift +// +// Copyright (c) 2017-2019 John Sundell. +// Licensed under the MIT license, as follows: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation +import Synchronization + +/// Type used to store information about a given file system location. You don't +/// interact with this type as part of the public API, instead you use the APIs +/// exposed by `Location`, `File`, and `Folder`. +public final class Storage: Sendable { + // `path` is mutated in place by `move`/`rename`, so it is guarded by a + // Mutex to keep `Storage` (and therefore `File`/`Folder`) `Sendable` + // without an unchecked conformance. The file manager is always the shared + // `.default` instance (which isn't `Sendable`), so it is accessed through a + // computed property rather than stored. + private let pathStorage: Mutex + + internal var path: String { pathStorage.withLock { $0 } } + + internal init(path: String) throws(LocationError) { + pathStorage = Mutex(try Storage.validatedPath(from: path)) + } + + // Paths are stored in canonical (forward-slash) form; the normalization logic + // is inherently branchy. + // swiftlint:disable:next cyclomatic_complexity + private static func validatedPath(from inputPath: String) throws(LocationError) -> String { + // Accept either separator on input and work in canonical (forward-slash) form. + var path = inputPath.canonicalizedPath + + switch LocationType.kind { + case .file: + guard !path.isEmpty else { + throw LocationError(path: path, reason: .emptyFilePath) + } + case .folder: + if path.isEmpty { path = FileManager.default.currentDirectoryPath.canonicalizedPath } + if !path.hasSuffix("/") { path += "/" } + } + + if path.hasPrefix("~") { + // `NSHomeDirectory()` is platform-aware (Windows resolves the user profile, + // not the POSIX-only `HOME` variable) and, unlike + // `FileManager.homeDirectoryForCurrentUser`, is available on every Apple + // platform (iOS/tvOS/watchOS included). + let homePath = NSHomeDirectory().canonicalizedPath + path = homePath + path.dropFirst() + } + + while let parentReferenceRange = path.range(of: "../") { + let folderPath = String(path[.. String? { + // An empty path means "the current directory" (for example when resolving a + // leading `../`). Resolve it before splitting off a Windows drive prefix; + // otherwise `URL(fileURLWithPath: "")` turns `C:` into a path component and + // produces the invalid canonical form `/C:/...`. + let path = + path.isEmpty + ? FileManager.default.currentDirectoryPath.canonicalizedPath + : path.canonicalizedPath + + guard path != "/", !path.isDriveRoot else { + return nil + } + + // On Windows, split off any drive prefix (e.g. "C:") so it can be restored + // after `URL.pathComponents` (which drops it) rejoins the remainder. On other + // platforms `drivePrefix` is empty and this is identical to the original logic. + let drivePrefix: String + let remainder: String + if let colon = path.firstIndex(of: ":") { + drivePrefix = String(path[...colon]) + remainder = String(path[path.index(after: colon)...]) + } else { + drivePrefix = "" + remainder = path + } + + let url = URL(fileURLWithPath: remainder.nativePath) + let components = url.pathComponents.dropFirst().dropLast() + guard !components.isEmpty else { + return drivePrefix.isEmpty ? "/" : drivePrefix + "/" + } + return drivePrefix + "/" + components.joined(separator: "/") + "/" +} + +extension Storage { + internal var attributes: [FileAttributeKey: Any] { + (try? FileManager.default.attributesOfItem(atPath: path.nativePath)) ?? [:] + } + + internal func move( + to newPath: String, + errorReasonProvider: (any Error & Sendable) -> LocationErrorReason + ) throws(LocationError) { + do { + try FileManager.default.moveItem(atPath: path.nativePath, toPath: newPath.nativePath) + + // Store the new path in canonical (forward-slash) form. + let canonical = newPath.canonicalizedPath + switch LocationType.kind { + case .file: + pathStorage.withLock { $0 = canonical } + case .folder: + pathStorage.withLock { $0 = canonical.appendingSuffixIfNeeded("/") } + } + } catch { + throw LocationError(path: path, reason: errorReasonProvider(error)) + } + } + + internal func copy(to newPath: String) throws(LocationError) { + do { + try FileManager.default.copyItem( + at: URL(fileURLWithPath: path.nativePath), + to: URL(fileURLWithPath: newPath.nativePath) + ) + } catch { + throw LocationError(path: path, reason: .copyFailed(error)) + } + } + + internal func delete() throws(LocationError) { + do { + try FileManager.default.removeItem(atPath: path.nativePath) + } catch { + throw LocationError(path: path, reason: .deleteFailed(error)) + } + } +} diff --git a/Sources/String+Files.swift b/Sources/String+Files.swift new file mode 100644 index 0000000..f13035e --- /dev/null +++ b/Sources/String+Files.swift @@ -0,0 +1,47 @@ +// +// String+Files.swift +// +// Copyright (c) 2017-2019 John Sundell. +// Licensed under the MIT license, as follows: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +extension String { + internal func removingPrefix(_ prefix: String) -> String { + guard hasPrefix(prefix) else { + return self + } + return String(dropFirst(prefix.count)) + } + + internal func removingSuffix(_ suffix: String) -> String { + guard hasSuffix(suffix) else { + return self + } + return String(dropLast(suffix.count)) + } + + internal func appendingSuffixIfNeeded(_ suffix: String) -> String { + guard !hasSuffix(suffix) else { + return self + } + return appending(suffix) + } +} diff --git a/Sources/WriteErrorReason.swift b/Sources/WriteErrorReason.swift new file mode 100644 index 0000000..339b953 --- /dev/null +++ b/Sources/WriteErrorReason.swift @@ -0,0 +1,38 @@ +// +// WriteErrorReason.swift +// +// Copyright (c) 2017-2019 John Sundell. +// Licensed under the MIT license, as follows: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +/// Enum listing reasons that a write operation could fail. +public enum WriteErrorReason: Sendable { + /// An empty path was given when writing or creating a location. + case emptyPath + /// A folder couldn't be created because of an underlying system error. + case folderCreationFailed(any Error & Sendable) + /// A file couldn't be created. + case fileCreationFailed + /// A file couldn't be written to because of an underlying system error. + case writeFailed(any Error & Sendable) + /// Failed to encode a string into binary data. + case stringEncodingFailed(String) +} diff --git a/Tests/FilesTests/FilesTests+Enumeration.swift b/Tests/FilesTests/FilesTests+Enumeration.swift new file mode 100644 index 0000000..10c08bd --- /dev/null +++ b/Tests/FilesTests/FilesTests+Enumeration.swift @@ -0,0 +1,160 @@ +// +// FilesTests+Enumeration.swift +// +// Copyright (c) 2017-2019 John Sundell. +// Licensed under the MIT license, as follows: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Files +import Foundation +import XCTest + +extension FilesTests { + internal func testEnumeratingFiles() { + performTest { + try folder.createFile(named: "1") + try folder.createFile(named: "2") + try folder.createFile(named: "3") + + // Hidden files should be excluded by default + try folder.createFile(named: ".hidden") + + XCTAssertEqual(folder.files.names().sorted(), ["1", "2", "3"]) + XCTAssertEqual(folder.files.count(), 3) + } + } + + internal func testEnumeratingFilesIncludingHidden() { + performTest { + let subfolder = try folder.createSubfolder(named: "folder") + try subfolder.createFile(named: ".hidden") + try subfolder.createFile(named: "visible") + + let files = subfolder.files.includingHidden + XCTAssertEqual(files.names().sorted(), [".hidden", "visible"]) + XCTAssertEqual(files.count(), 2) + } + } + + internal func testEnumeratingFilesRecursively() { + performTest { + let subfolder1 = try folder.createSubfolder(named: "1") + let subfolder2 = try folder.createSubfolder(named: "2") + + let subfolder1A = try subfolder1.createSubfolder(named: "A") + let subfolder1B = try subfolder1.createSubfolder(named: "B") + + let subfolder2A = try subfolder2.createSubfolder(named: "A") + let subfolder2B = try subfolder2.createSubfolder(named: "B") + + try subfolder1.createFile(named: "File1") + try subfolder1A.createFile(named: "File1A") + try subfolder1B.createFile(named: "File1B") + try subfolder2.createFile(named: "File2") + try subfolder2A.createFile(named: "File2A") + try subfolder2B.createFile(named: "File2B") + + let expectedNames = ["File1", "File1A", "File1B", "File2", "File2A", "File2B"] + let sequence = folder.files.recursive + XCTAssertEqual(sequence.names(), expectedNames) + XCTAssertEqual(sequence.count(), 6) + } + } + + internal func testEnumeratingSubfolders() { + performTest { + try folder.createSubfolder(named: "1") + try folder.createSubfolder(named: "2") + try folder.createSubfolder(named: "3") + + XCTAssertEqual(folder.subfolders.names(), ["1", "2", "3"]) + XCTAssertEqual(folder.subfolders.count(), 3) + } + } + + internal func testEnumeratingSubfoldersRecursively() { + performTest { + let subfolder1 = try folder.createSubfolder(named: "1") + let subfolder2 = try folder.createSubfolder(named: "2") + + try subfolder1.createSubfolder(named: "1A") + try subfolder1.createSubfolder(named: "1B") + + try subfolder2.createSubfolder(named: "2A") + try subfolder2.createSubfolder(named: "2B") + + let expectedNames = ["1", "1A", "1B", "2", "2A", "2B"] + let sequence = folder.subfolders.recursive + XCTAssertEqual(sequence.names().sorted(), expectedNames) + XCTAssertEqual(sequence.count(), 6) + } + } + + internal func testRenamingFoldersWhileEnumeratingSubfoldersRecursively() { + performTest { + let subfolder1 = try folder.createSubfolder(named: "1") + let subfolder2 = try folder.createSubfolder(named: "2") + + try subfolder1.createSubfolder(named: "1A") + try subfolder1.createSubfolder(named: "1B") + + try subfolder2.createSubfolder(named: "2A") + try subfolder2.createSubfolder(named: "2B") + + let sequence = folder.subfolders.recursive + + for folder in sequence { + try folder.rename(to: "Folder " + folder.name) + } + + let expectedNames = [ + "Folder 1", "Folder 1A", "Folder 1B", "Folder 2", "Folder 2A", "Folder 2B", + ] + + XCTAssertEqual(sequence.names().sorted(), expectedNames) + XCTAssertEqual(sequence.count(), 6) + } + } + + internal func testFirstAndLastInFileSequence() { + performTest { + try folder.createFile(named: "A") + try folder.createFile(named: "B") + try folder.createFile(named: "C") + + XCTAssertEqual(folder.files.first?.name, "A") + XCTAssertEqual(folder.files.last()?.name, "C") + } + } + + internal func testConvertingFileSequenceToRecursive() { + performTest { + try folder.createFile(named: "A") + try folder.createFile(named: "B") + + let subfolder = try folder.createSubfolder(named: "1") + try subfolder.createFile(named: "1A") + + let names = folder.files.recursive.names() + XCTAssertEqual(names, ["A", "B", "1A"]) + } + } +} diff --git a/Tests/FilesTests/FilesTests+Files.swift b/Tests/FilesTests/FilesTests+Files.swift new file mode 100644 index 0000000..f53d700 --- /dev/null +++ b/Tests/FilesTests/FilesTests+Files.swift @@ -0,0 +1,191 @@ +// +// FilesTests+Files.swift +// +// Copyright (c) 2017-2019 John Sundell. +// Licensed under the MIT license, as follows: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Files +import Foundation +import XCTest + +extension FilesTests { + internal func testCreatingFileAtPath() { + performTest { + let path = "a/b/c.txt" + + XCTAssertFalse(folder.containsFile(at: path)) + try folder.createFile(at: path, contents: Data("Hello".utf8)) + + XCTAssertTrue(folder.containsFile(at: path)) + XCTAssertTrue(folder.containsSubfolder(named: "a")) + XCTAssertTrue(folder.containsSubfolder(at: "a/b")) + + let file = try folder.createFileIfNeeded(at: path) + XCTAssertEqual(try file.readAsString(), "Hello") + } + } + + internal func testCreatingFileIfNeededAtPath() { + performTest { + let path = "a/b/c.txt" + + XCTAssertFalse(folder.containsFile(at: path)) + var file = try folder.createFileIfNeeded(at: path, contents: Data("Hello".utf8)) + + XCTAssertTrue(folder.containsFile(at: path)) + XCTAssertTrue(folder.containsSubfolder(named: "a")) + XCTAssertTrue(folder.containsSubfolder(at: "a/b")) + + file = try folder.createFileIfNeeded(at: path, contents: Data()) + XCTAssertEqual(try file.readAsString(), "Hello") + } + } + + internal func testDroppingLeadingSlashWhenCreatingFileAtPath() { + performTest { + let path = "/a/b/c.txt" + + XCTAssertFalse(folder.containsFile(at: path)) + try folder.createFile(at: path, contents: Data("Hello".utf8)) + + XCTAssertTrue(folder.containsFile(at: path)) + XCTAssertTrue(folder.containsSubfolder(named: "a")) + XCTAssertTrue(folder.containsSubfolder(at: "/a/b")) + + let file = try folder.createFileIfNeeded(at: path) + XCTAssertEqual(try file.readAsString(), "Hello") + } + } + + internal func testReadingFileAsString() { + performTest { + let file = try folder.createFile(named: "string", contents: Data("Hello".utf8)) + try XCTAssertEqual(file.readAsString(), "Hello") + } + } + + internal func testReadingFileAsInt() { + performTest { + let intFile = try folder.createFile(named: "int", contents: Data("\(7)".utf8)) + try XCTAssertEqual(intFile.readAsInt(), 7) + + let nonIntFile = try folder.createFile( + named: "nonInt", + contents: Data("Not an int".utf8) + ) + try assert(nonIntFile.readAsInt(), throwsErrorOfType: ReadError.self) + } + } + + internal func testReadingFileWithTildePath() { + performTest { + try folder.createFile(named: "File") + let file = try File(path: "~/.filesTest/File") + try XCTAssertEqual(file.read(), Data()) + XCTAssertEqual(file.path, folder.path + "File") + + // Cleanup since we're performing a test in the actual home folder + try file.delete() + } + } + + internal func testAccesingFileByPath() { + performTest { + let subfolderA = try folder.createSubfolder(named: "A") + let subfolderB = try subfolderA.createSubfolder(named: "B") + let file = try subfolderB.createFile(named: "C") + try XCTAssertEqual(folder.file(at: "A/B/C"), file) + } + } + + internal func testWritingDataToFile() { + performTest { + let file = try folder.createFile(named: "file") + try XCTAssertEqual(file.read(), Data()) + + let data = Data("New content".utf8) + try file.write(data) + try XCTAssertEqual(file.read(), data) + } + } + + internal func testWritingStringToFile() { + performTest { + let file = try folder.createFile(named: "file") + try XCTAssertEqual(file.read(), Data()) + + try file.write("New content") + try XCTAssertEqual(file.read(), Data("New content".utf8)) + } + } + + internal func testAppendingDataToFile() { + performTest { + let file = try folder.createFile(named: "file") + let data = Data("Old content\n".utf8) + try file.write(data) + + let newData = Data("I'm the appended content 💯\n".utf8) + try file.append(newData) + try XCTAssertEqual( + file.read(), + Data("Old content\nI'm the appended content 💯\n".utf8) + ) + } + } + + internal func testAppendingStringToFile() { + performTest { + let file = try folder.createFile(named: "file") + try file.write("Old content\n") + + let newString = "I'm the appended content 💯\n" + try file.append(newString) + try XCTAssertEqual( + file.read(), + Data("Old content\nI'm the appended content 💯\n".utf8) + ) + } + } + + internal func testCreateFileIfNeeded() { + performTest { + let fileA = try folder.createFileIfNeeded( + withName: "file", + contents: Data("Hello".utf8) + ) + let fileB = try folder.createFileIfNeeded( + withName: "file", + contents: Data("World".utf8) + ) + try XCTAssertEqual(fileA.readAsString(), "Hello") + try XCTAssertEqual(fileA.read(), fileB.read()) + } + } + + internal func testCreatingFileWithString() { + performTest { + let file = try folder.createFile(named: "file", contents: Data("Hello world".utf8)) + XCTAssertEqual(try file.readAsString(), "Hello world") + } + } +} diff --git a/Tests/FilesTests/FilesTests+Folders.swift b/Tests/FilesTests/FilesTests+Folders.swift new file mode 100644 index 0000000..11d7364 --- /dev/null +++ b/Tests/FilesTests/FilesTests+Folders.swift @@ -0,0 +1,108 @@ +// +// FilesTests+Folders.swift +// +// Copyright (c) 2017-2019 John Sundell. +// Licensed under the MIT license, as follows: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Files +import Foundation +import XCTest + +extension FilesTests { + internal func testCreatingSubfolderAtPath() { + performTest { + let path = "a/b/c" + + XCTAssertFalse(folder.containsSubfolder(at: path)) + try folder.createSubfolder(at: path).createFile(named: "d.txt") + + XCTAssertTrue(folder.containsSubfolder(at: path)) + XCTAssertTrue(folder.containsSubfolder(named: "a")) + XCTAssertTrue(folder.containsSubfolder(at: "a/b")) + XCTAssertTrue(folder.containsFile(at: "a/b/c/d.txt")) + + let subfolder = try folder.createSubfolderIfNeeded(at: path) + XCTAssertEqual(subfolder.files.names(), ["d.txt"]) + } + } + + internal func testDroppingLeadingSlashWhenCreatingSubfolderAtPath() { + performTest { + let path = "a/b/c" + + XCTAssertFalse(folder.containsSubfolder(at: path)) + try folder.createSubfolder(at: path).createFile(named: "d.txt") + + XCTAssertTrue(folder.containsSubfolder(at: path)) + XCTAssertTrue(folder.containsSubfolder(named: "a")) + XCTAssertTrue(folder.containsSubfolder(at: "/a/b")) + XCTAssertTrue(folder.containsFile(at: "/a/b/c/d.txt")) + + let subfolder = try folder.createSubfolderIfNeeded(at: path) + XCTAssertEqual(subfolder.files.names(), ["d.txt"]) + } + } + + internal func testCreateFolderIfNeeded() { + performTest { + let subfolderA = try folder.createSubfolderIfNeeded(withName: "Subfolder") + try subfolderA.createFile(named: "file") + let subfolderB = try folder.createSubfolderIfNeeded(withName: subfolderA.name) + XCTAssertEqual(subfolderA, subfolderB) + XCTAssertEqual(subfolderA.files.count(), subfolderB.files.count()) + XCTAssertEqual(subfolderA.files.first, subfolderB.files.first) + } + } + + internal func testCreateSubfolderIfNeeded() { + performTest { + let subfolderA = try folder.createSubfolderIfNeeded(withName: "folder") + try subfolderA.createFile(named: "file") + let subfolderB = try folder.createSubfolderIfNeeded(withName: "folder") + XCTAssertEqual(subfolderA, subfolderB) + XCTAssertEqual(subfolderA.files.count(), subfolderB.files.count()) + XCTAssertEqual(subfolderA.files.first, subfolderB.files.first) + } + } + + internal func testFolderContainsFile() { + performTest { + let subfolder = try folder.createSubfolder(named: "subfolder") + let fileA = try subfolder.createFile(named: "A") + XCTAssertFalse(folder.contains(fileA)) + + let fileB = try folder.createFile(named: "B") + XCTAssertTrue(folder.contains(fileB)) + } + } + + internal func testFolderContainsSubfolder() { + performTest { + let subfolder = try folder.createSubfolder(named: "subfolder") + let subfolderA = try subfolder.createSubfolder(named: "A") + XCTAssertFalse(folder.contains(subfolderA)) + + let subfolderB = try folder.createSubfolder(named: "B") + XCTAssertTrue(folder.contains(subfolderB)) + } + } +} diff --git a/Tests/FilesTests/FilesTests+Metadata.swift b/Tests/FilesTests/FilesTests+Metadata.swift new file mode 100644 index 0000000..9bc80c4 --- /dev/null +++ b/Tests/FilesTests/FilesTests+Metadata.swift @@ -0,0 +1,223 @@ +// +// FilesTests+Metadata.swift +// +// Copyright (c) 2017-2019 John Sundell. +// Licensed under the MIT license, as follows: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Files +import Foundation +import XCTest + +extension FilesTests { + internal func testReadingFileWithRelativePath() { + performTest { + try folder.createFile(named: "file") + + // Make sure we're not already in the file's parent directory + XCTAssertNotEqual(FileManager.default.currentDirectoryPath, folder.path) + + XCTAssertTrue(FileManager.default.changeCurrentDirectoryPath(folder.path)) + let file = try File(path: "file") + try XCTAssertEqual(file.read(), Data()) + } + } + + internal func testReadingFileFromCurrentFoldersParent() { + performTest { + let subfolder = try folder.createSubfolder(named: "folder") + let file = try folder.createFile(named: "file") + + // Move to the subfolder + XCTAssertNotEqual(FileManager.default.currentDirectoryPath, subfolder.path) + XCTAssertTrue(FileManager.default.changeCurrentDirectoryPath(subfolder.path)) + + try XCTAssertEqual(File(path: "../file"), file) + } + } + + internal func testReadingFileWithMultipleParentReferencesWithinPath() { + performTest { + let subfolderA = try folder.createSubfolder(named: "A") + try folder.createSubfolder(named: "B") + let subfolderC = try folder.createSubfolder(named: "C") + let file = try subfolderC.createFile(named: "file") + + try XCTAssertEqual(File(path: subfolderA.path + "../B/../C/file"), file) + } + } + + internal func testAccessingSubfolderByPath() { + performTest { + let subfolderA = try folder.createSubfolder(named: "A") + let subfolderB = try subfolderA.createSubfolder(named: "B") + let subfolderC = try subfolderB.createSubfolder(named: "C") + try XCTAssertEqual(folder.subfolder(at: "A/B/C"), subfolderC) + } + } + + internal func testModificationDate() { + performTest { + let subfolder = try folder.createSubfolder(named: "Folder") + XCTAssertTrue(subfolder.modificationDate.map(Calendar.current.isDateInToday) ?? false) + + let file = try folder.createFile(named: "File") + XCTAssertTrue(file.modificationDate.map(Calendar.current.isDateInToday) ?? false) + } + } + + internal func testParent() { + performTest { + try XCTAssertEqual(folder.createFile(named: "test").parent, folder) + + let subfolder = try folder.createSubfolder(named: "subfolder") + XCTAssertEqual(subfolder.parent, folder) + try XCTAssertEqual(subfolder.createFile(named: "test").parent, subfolder) + } + } + + internal func testRootFolderParentIsNil() { + performTest { + try XCTAssertNil(Folder(path: Self.rootPath).parent) + } + } + + internal func testRootSubfolderParentIsRoot() { + performTest { + let rootFolder = try Folder(path: Self.rootPath) + let subfolder = rootFolder.subfolders.first + XCTAssertEqual(subfolder?.parent, rootFolder) + } + } + + internal func testFileDescription() { + performTest { + let file = try folder.createFile(named: "file") + XCTAssertEqual(file.description, "File(name: file, path: \(folder.path)file)") + } + } + + internal func testFolderDescription() { + performTest { + let subfolder = try folder.createSubfolder(named: "folder") + XCTAssertEqual(subfolder.description, "Folder(name: folder, path: \(folder.path)folder/)") + } + } + + internal func testFilesDescription() { + performTest { + let fileA = try folder.createFile(named: "fileA") + let fileB = try folder.createFile(named: "fileB") + XCTAssertEqual(folder.files.description, "\(fileA.description)\n\(fileB.description)") + } + } + + internal func testSubfoldersDescription() { + performTest { + let folderA = try folder.createSubfolder(named: "folderA") + let folderB = try folder.createSubfolder(named: "folderB") + XCTAssertEqual( + folder.subfolders.description, + "\(folderA.description)\n\(folderB.description)" + ) + } + } + + internal func testAccessingHomeFolder() { + XCTAssertNotNil(Folder.home) + } + + internal func testAccessingCurrentWorkingDirectory() { + performTest { + let folder = try Folder(path: "") + // `folder.path` is in canonical (forward-slash) form; the current directory + // reported by FileManager uses the platform's native separator on Windows. + XCTAssertEqual(canonical(FileManager.default.currentDirectoryPath) + "/", folder.path) + XCTAssertEqual(Folder.current, folder) + } + } + + internal func testNameExcludingExtensionWithLongFileName() { + performTest { + let file = try folder.createFile(named: "AVeryLongFileName.png") + XCTAssertEqual(file.nameExcludingExtension, "AVeryLongFileName") + } + } + + internal func testNameExcludingExtensionWithoutExtension() { + performTest { + let file = try folder.createFile(named: "File") + let subfolder = try folder.createSubfolder(named: "Subfolder") + + XCTAssertEqual(file.nameExcludingExtension, "File") + XCTAssertEqual(subfolder.nameExcludingExtension, "Subfolder") + } + } + + internal func testRelativePaths() { + performTest { + let file = try folder.createFile(named: "FileA") + let subfolder = try folder.createSubfolder(named: "Folder") + let fileInSubfolder = try subfolder.createFile(named: "FileB") + + XCTAssertEqual(file.path(relativeTo: folder), "FileA") + XCTAssertEqual(subfolder.path(relativeTo: folder), "Folder") + XCTAssertEqual(fileInSubfolder.path(relativeTo: folder), "Folder/FileB") + } + } + + internal func testRelativePathIsAbsolutePathForNonParent() { + performTest { + let file = try folder.createFile(named: "FileA") + let subfolder = try folder.createSubfolder(named: "Folder") + + XCTAssertEqual(file.path(relativeTo: subfolder), file.path) + } + } + + internal func testErrorDescriptions() { + let missingError = FilesError( + path: "/some/path", + reason: LocationErrorReason.missing + ) + + XCTAssertEqual( + missingError.description, + """ + Files encountered an error at '/some/path'. + Reason: missing + """ + ) + + let encodingError = FilesError( + path: "/some/path", + reason: WriteErrorReason.stringEncodingFailed("Hello") + ) + + XCTAssertEqual( + encodingError.description, + """ + Files encountered an error at '/some/path'. + Reason: stringEncodingFailed(\"Hello\") + """ + ) + } +} diff --git a/Tests/FilesTests/FilesTests+MovingCopying.swift b/Tests/FilesTests/FilesTests+MovingCopying.swift new file mode 100644 index 0000000..c280b6e --- /dev/null +++ b/Tests/FilesTests/FilesTests+MovingCopying.swift @@ -0,0 +1,139 @@ +// +// FilesTests+MovingCopying.swift +// +// Copyright (c) 2017-2019 John Sundell. +// Licensed under the MIT license, as follows: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Files +import Foundation +import XCTest + +extension FilesTests { + internal func testMovingFiles() { + performTest { + try folder.createFile(named: "A") + try folder.createFile(named: "B") + XCTAssertEqual(folder.files.count(), 2) + + let subfolder = try folder.createSubfolder(named: "folder") + try folder.files.move(to: subfolder) + try XCTAssertNotNil(subfolder.file(named: "A")) + try XCTAssertNotNil(subfolder.file(named: "B")) + XCTAssertEqual(folder.files.count(), 0) + } + } + + internal func testCopyingFiles() { + performTest { + let file = try folder.createFile(named: "A") + try file.write("content") + + let subfolder = try folder.createSubfolder(named: "folder") + let copiedFile = try file.copy(to: subfolder) + try XCTAssertNotNil(folder.file(named: "A")) + try XCTAssertNotNil(subfolder.file(named: "A")) + try XCTAssertEqual(file.read(), subfolder.file(named: "A").read()) + try XCTAssertEqual(copiedFile, subfolder.file(named: "A")) + XCTAssertEqual(folder.files.count(), 1) + } + } + + internal func testMovingFolders() { + performTest { + let folderA = try folder.createSubfolder(named: "A") + let folderB = try folderA.createSubfolder(named: "B") + _ = try folderB.createSubfolder(named: "C") + + try folderB.move(to: folder) + XCTAssertTrue(folder.containsSubfolder(named: "B")) + XCTAssertTrue(folderB.containsSubfolder(named: "C")) + } + } + + internal func testCopyingFolders() { + performTest { + let copyingFolder = try folder.createSubfolder(named: "A") + + let subfolder = try folder.createSubfolder(named: "folder") + let copiedFolder = try copyingFolder.copy(to: subfolder) + XCTAssertTrue(folder.containsSubfolder(named: "A")) + XCTAssertTrue(subfolder.containsSubfolder(named: "A")) + XCTAssertEqual(copiedFolder, try subfolder.subfolder(named: "A")) + XCTAssertEqual(folder.subfolders.count(), 2) + XCTAssertEqual(subfolder.subfolders.count(), 1) + } + } + + internal func testOpeningFileWithEmptyPathThrows() { + performTest { + try assert(File(path: ""), throwsErrorOfType: LocationError.self) + } + } + + internal func testDeletingNonExistingFileThrows() { + performTest { + let file = try folder.createFile(named: "file") + try file.delete() + try assert(file.delete(), throwsErrorOfType: LocationError.self) + } + } + + internal func testMovingFolderContents() { + performTest { + let parentFolder = try folder.createSubfolder(named: "parentA") + try parentFolder.createSubfolder(named: "folderA") + try parentFolder.createSubfolder(named: "folderB") + try parentFolder.createFile(named: "fileA") + try parentFolder.createFile(named: "fileB") + + XCTAssertEqual(parentFolder.subfolders.names(), ["folderA", "folderB"]) + XCTAssertEqual(parentFolder.files.names(), ["fileA", "fileB"]) + + let newParentFolder = try folder.createSubfolder(named: "parentB") + try parentFolder.moveContents(to: newParentFolder) + + XCTAssertEqual(parentFolder.subfolders.names(), []) + XCTAssertEqual(parentFolder.files.names(), []) + XCTAssertEqual(newParentFolder.subfolders.names(), ["folderA", "folderB"]) + XCTAssertEqual(newParentFolder.files.names(), ["fileA", "fileB"]) + } + } + + internal func testMovingFolderHiddenContents() { + performTest { + let parentFolder = try folder.createSubfolder(named: "parent") + try parentFolder.createFile(named: ".hidden") + try parentFolder.createSubfolder(named: ".folder") + + XCTAssertEqual(parentFolder.files.includingHidden.names(), [".hidden"]) + XCTAssertEqual(parentFolder.subfolders.includingHidden.names(), [".folder"]) + + let newParentFolder = try folder.createSubfolder(named: "parentB") + try parentFolder.moveContents(to: newParentFolder, includeHidden: true) + + XCTAssertEqual(parentFolder.files.includingHidden.names(), []) + XCTAssertEqual(parentFolder.subfolders.includingHidden.names(), []) + XCTAssertEqual(newParentFolder.files.includingHidden.names(), [".hidden"]) + XCTAssertEqual(newParentFolder.subfolders.includingHidden.names(), [".folder"]) + } + } +} diff --git a/Tests/FilesTests/FilesTests+Operations.swift b/Tests/FilesTests/FilesTests+Operations.swift new file mode 100644 index 0000000..b542f38 --- /dev/null +++ b/Tests/FilesTests/FilesTests+Operations.swift @@ -0,0 +1,168 @@ +// +// FilesTests+Operations.swift +// +// Copyright (c) 2017-2019 John Sundell. +// Licensed under the MIT license, as follows: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Files +import Foundation +import XCTest + +extension FilesTests { + internal func testCreatingAndDeletingFile() { + performTest { + // Verify that the file doesn't exist + XCTAssertFalse(folder.containsFile(named: "test.txt")) + + // Create a file and verify its properties + let file = try folder.createFile(named: "test.txt") + XCTAssertEqual(file.name, "test.txt") + XCTAssertEqual(file.path, folder.path + "test.txt") + XCTAssertEqual(file.extension, "txt") + XCTAssertEqual(file.nameExcludingExtension, "test") + try XCTAssertEqual(file.read(), Data()) + + // You should now be able to access the file using its path and through the parent + _ = try File(path: file.path) + XCTAssertTrue(folder.containsFile(named: "test.txt")) + + try file.delete() + + // Attempting to read the file should now throw an error + try assert(file.read(), throwsErrorOfType: ReadError.self) + + // Attempting to create a File instance with the path should now also fail + try assert(File(path: file.path), throwsErrorOfType: LocationError.self) + } + } + + internal func testCreatingAndDeletingFolder() { + performTest { + // Verify that the folder doesn't exist + XCTAssertFalse(folder.containsSubfolder(named: "folder")) + + // Create a folder and verify its properties + let subfolder = try folder.createSubfolder(named: "folder") + XCTAssertEqual(subfolder.name, "folder") + XCTAssertEqual(subfolder.path, folder.path + "folder/") + + // You should now be able to access the folder using its path and through the parent + _ = try Folder(path: subfolder.path) + XCTAssertTrue(folder.containsSubfolder(named: "folder")) + + // Put a file in the folder + let file = try subfolder.createFile(named: "file") + try XCTAssertEqual(file.read(), Data()) + + try subfolder.delete() + + // Attempting to create a Folder instance with the path should now fail + try assert(Folder(path: subfolder.path), throwsErrorOfType: LocationError.self) + + // The file contained in the folder should now also be deleted + try assert(file.read(), throwsErrorOfType: ReadError.self) + } + } + + internal func testRenamingFile() { + performTest { + let file = try folder.createFile(named: "file.json") + try file.rename(to: "renamedFile") + XCTAssertEqual(file.name, "renamedFile.json") + XCTAssertEqual(file.path, folder.path + "renamedFile.json") + XCTAssertEqual(file.extension, "json") + + // Now try renaming the file, replacing its extension + try file.rename(to: "other.txt", keepExtension: false) + XCTAssertEqual(file.name, "other.txt") + XCTAssertEqual(file.path, folder.path + "other.txt") + XCTAssertEqual(file.extension, "txt") + } + } + + internal func testRenamingFileWithNameIncludingExtension() { + performTest { + let file = try folder.createFile(named: "file.json") + try file.rename(to: "renamedFile.json") + XCTAssertEqual(file.name, "renamedFile.json") + XCTAssertEqual(file.path, folder.path + "renamedFile.json") + XCTAssertEqual(file.extension, "json") + } + } + + internal func testRenamingFolder() { + performTest { + let subfolder = try folder.createSubfolder(named: "folder") + try subfolder.rename(to: "renamedFolder") + XCTAssertEqual(subfolder.name, "renamedFolder") + XCTAssertEqual(subfolder.path, folder.path + "renamedFolder/") + } + } + + internal func testEmptyingFolder() { + performTest { + try folder.createFile(named: "A") + try folder.createFile(named: "B") + XCTAssertEqual(folder.files.count(), 2) + + try folder.empty() + XCTAssertEqual(folder.files.count(), 0) + } + } + + internal func testEmptyingFolderWithHiddenFiles() { + performTest { + let subfolder = try folder.createSubfolder(named: "folder") + + try subfolder.createFile(named: "A") + try subfolder.createFile(named: ".B") + XCTAssertEqual(subfolder.files.includingHidden.count(), 2) + + // Per default, hidden files should not be deleted + try subfolder.empty() + XCTAssertEqual(subfolder.files.includingHidden.count(), 1) + + try subfolder.empty(includingHidden: true) + XCTAssertEqual(folder.files.count(), 0) + } + } + + internal func testCheckingEmptyFolders() { + performTest { + let emptySubfolder = try folder.createSubfolder(named: "1") + XCTAssertTrue(emptySubfolder.isEmpty()) + + let subfolderWithFile = try folder.createSubfolder(named: "2") + try subfolderWithFile.createFile(named: "A") + XCTAssertFalse(subfolderWithFile.isEmpty()) + + let subfolderWithHiddenFile = try folder.createSubfolder(named: "3") + try subfolderWithHiddenFile.createFile(named: ".B") + XCTAssertTrue(subfolderWithHiddenFile.isEmpty()) + XCTAssertFalse(subfolderWithHiddenFile.isEmpty(includingHidden: true)) + + let subfolderWithFolder = try folder.createSubfolder(named: "3") + try subfolderWithFolder.createSubfolder(named: "4") + XCTAssertFalse(subfolderWithFile.isEmpty()) + } + } +} diff --git a/Tests/FilesTests/FilesTests+SearchPath.swift b/Tests/FilesTests/FilesTests+SearchPath.swift new file mode 100644 index 0000000..f454526 --- /dev/null +++ b/Tests/FilesTests/FilesTests+SearchPath.swift @@ -0,0 +1,76 @@ +// +// FilesTests+SearchPath.swift +// +// Copyright (c) 2017-2019 John Sundell. +// Licensed under the MIT license, as follows: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Files +import Foundation +import XCTest + +#if os(macOS) + extension FilesTests { + internal func testAccessingDocumentsFolder() { + XCTAssertNotNil(Folder.documents, "Documents folder should be available.") + } + } +#endif + +#if os(iOS) || os(tvOS) || os(macOS) + extension FilesTests { + internal func testAccessingLibraryFolder() { + XCTAssertNotNil(Folder.library, "Library folder should be available.") + } + + internal func testResolvingFolderMatchingSearchPath() { + performTest { + // Real file I/O + XCTAssertNotNil(try Folder.matching(.cachesDirectory)) + XCTAssertNotNil(try Folder.matching(.libraryDirectory)) + + // Mocked file I/O + final class FileManagerMock: FileManager { + var target: Folder? + + override func urls( + for directory: FileManager.SearchPathDirectory, + in domainMask: FileManager.SearchPathDomainMask + ) -> [URL] { + target.map { [$0.url] } ?? [] + } + } + + let target = try folder.createSubfolder(named: "Target") + + let fileManager = FileManagerMock() + fileManager.target = target + + let resolved = try Folder.matching( + .documentDirectory, + resolvedBy: fileManager + ) + + XCTAssertEqual(resolved, target) + } + } + } +#endif diff --git a/Tests/FilesTests/FilesTests.swift b/Tests/FilesTests/FilesTests.swift index 040f60e..98829ea 100644 --- a/Tests/FilesTests/FilesTests.swift +++ b/Tests/FilesTests/FilesTests.swift @@ -1,985 +1,188 @@ -/** - * Files - * - * Copyright (c) 2017-2019 John Sundell. Licensed under the MIT license, as follows: - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ +// +// FilesTests.swift +// +// Copyright (c) 2017-2019 John Sundell. +// Licensed under the MIT license, as follows: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +import Files import Foundation import XCTest -import Files - -class FilesTests: XCTestCase { - private var folder: Folder! - - // MARK: - XCTestCase - - override func setUp() { - super.setUp() - folder = try! Folder.home.createSubfolderIfNeeded(withName: ".filesTest") - try! folder.empty() - } - - override func tearDown() { - try? folder.delete() - super.tearDown() - } - - // MARK: - Tests - - func testCreatingAndDeletingFile() { - performTest { - // Verify that the file doesn't exist - XCTAssertFalse(folder.containsFile(named: "test.txt")) - - // Create a file and verify its properties - let file = try folder.createFile(named: "test.txt") - XCTAssertEqual(file.name, "test.txt") - XCTAssertEqual(file.path, folder.path + "test.txt") - XCTAssertEqual(file.extension, "txt") - XCTAssertEqual(file.nameExcludingExtension, "test") - try XCTAssertEqual(file.read(), Data()) - - // You should now be able to access the file using its path and through the parent - _ = try File(path: file.path) - XCTAssertTrue(folder.containsFile(named: "test.txt")) - - try file.delete() - - // Attempting to read the file should now throw an error - try assert(file.read(), throwsErrorOfType: ReadError.self) - - // Attempting to create a File instance with the path should now also fail - try assert(File(path: file.path), throwsErrorOfType: LocationError.self) - } - } - - func testCreatingFileAtPath() { - performTest { - let path = "a/b/c.txt" - - XCTAssertFalse(folder.containsFile(at: path)) - try folder.createFile(at: path, contents: Data("Hello".utf8)) - - XCTAssertTrue(folder.containsFile(at: path)) - XCTAssertTrue(folder.containsSubfolder(named: "a")) - XCTAssertTrue(folder.containsSubfolder(at: "a/b")) - - let file = try folder.createFileIfNeeded(at: path) - XCTAssertEqual(try file.readAsString(), "Hello") - } - } - - func testCreatingFileIfNeededAtPath() { - performTest { - let path = "a/b/c.txt" - - XCTAssertFalse(folder.containsFile(at: path)) - var file = try folder.createFileIfNeeded(at: path, contents: Data("Hello".utf8)) - - XCTAssertTrue(folder.containsFile(at: path)) - XCTAssertTrue(folder.containsSubfolder(named: "a")) - XCTAssertTrue(folder.containsSubfolder(at: "a/b")) - - file = try folder.createFileIfNeeded(at: path, contents: Data()) - XCTAssertEqual(try file.readAsString(), "Hello") - } - } - - func testDroppingLeadingSlashWhenCreatingFileAtPath() { - performTest { - let path = "/a/b/c.txt" - - XCTAssertFalse(folder.containsFile(at: path)) - try folder.createFile(at: path, contents: Data("Hello".utf8)) - - XCTAssertTrue(folder.containsFile(at: path)) - XCTAssertTrue(folder.containsSubfolder(named: "a")) - XCTAssertTrue(folder.containsSubfolder(at: "/a/b")) - - let file = try folder.createFileIfNeeded(at: path) - XCTAssertEqual(try file.readAsString(), "Hello") - } - } - - func testCreatingAndDeletingFolder() { - performTest { - // Verify that the folder doesn't exist - XCTAssertFalse(folder.containsSubfolder(named: "folder")) - - // Create a folder and verify its properties - let subfolder = try folder.createSubfolder(named: "folder") - XCTAssertEqual(subfolder.name, "folder") - XCTAssertEqual(subfolder.path, folder.path + "folder/") - - // You should now be able to access the folder using its path and through the parent - _ = try Folder(path: subfolder.path) - XCTAssertTrue(folder.containsSubfolder(named: "folder")) - - // Put a file in the folder - let file = try subfolder.createFile(named: "file") - try XCTAssertEqual(file.read(), Data()) - - try subfolder.delete() - - // Attempting to create a Folder instance with the path should now fail - try assert(Folder(path: subfolder.path), throwsErrorOfType: LocationError.self) - - // The file contained in the folder should now also be deleted - try assert(file.read(), throwsErrorOfType: ReadError.self) - } - } - - func testCreatingSubfolderAtPath() { - performTest { - let path = "a/b/c" - - XCTAssertFalse(folder.containsSubfolder(at: path)) - try folder.createSubfolder(at: path).createFile(named: "d.txt") - - XCTAssertTrue(folder.containsSubfolder(at: path)) - XCTAssertTrue(folder.containsSubfolder(named: "a")) - XCTAssertTrue(folder.containsSubfolder(at: "a/b")) - XCTAssertTrue(folder.containsFile(at: "a/b/c/d.txt")) - - let subfolder = try folder.createSubfolderIfNeeded(at: path) - XCTAssertEqual(subfolder.files.names(), ["d.txt"]) - } - } - - func testDroppingLeadingSlashWhenCreatingSubfolderAtPath() { - performTest { - let path = "a/b/c" - - XCTAssertFalse(folder.containsSubfolder(at: path)) - try folder.createSubfolder(at: path).createFile(named: "d.txt") - - XCTAssertTrue(folder.containsSubfolder(at: path)) - XCTAssertTrue(folder.containsSubfolder(named: "a")) - XCTAssertTrue(folder.containsSubfolder(at: "/a/b")) - XCTAssertTrue(folder.containsFile(at: "/a/b/c/d.txt")) - - let subfolder = try folder.createSubfolderIfNeeded(at: path) - XCTAssertEqual(subfolder.files.names(), ["d.txt"]) - } - } - - func testReadingFileAsString() { - performTest { - let file = try folder.createFile(named: "string", contents: "Hello".data(using: .utf8)!) - try XCTAssertEqual(file.readAsString(), "Hello") - } - } - - func testReadingFileAsInt() { - performTest { - let intFile = try folder.createFile(named: "int", contents: "\(7)".data(using: .utf8)!) - try XCTAssertEqual(intFile.readAsInt(), 7) - - let nonIntFile = try folder.createFile(named: "nonInt", contents: "Not an int".data(using: .utf8)!) - try assert(nonIntFile.readAsInt(), throwsErrorOfType: ReadError.self) - } - } - - func testRenamingFile() { - performTest { - let file = try folder.createFile(named: "file.json") - try file.rename(to: "renamedFile") - XCTAssertEqual(file.name, "renamedFile.json") - XCTAssertEqual(file.path, folder.path + "renamedFile.json") - XCTAssertEqual(file.extension, "json") - - // Now try renaming the file, replacing its extension - try file.rename(to: "other.txt", keepExtension: false) - XCTAssertEqual(file.name, "other.txt") - XCTAssertEqual(file.path, folder.path + "other.txt") - XCTAssertEqual(file.extension, "txt") - } - } - - func testRenamingFileWithNameIncludingExtension() { - performTest { - let file = try folder.createFile(named: "file.json") - try file.rename(to: "renamedFile.json") - XCTAssertEqual(file.name, "renamedFile.json") - XCTAssertEqual(file.path, folder.path + "renamedFile.json") - XCTAssertEqual(file.extension, "json") - } - } - - func testReadingFileWithRelativePath() { - performTest { - try folder.createFile(named: "file") - - // Make sure we're not already in the file's parent directory - XCTAssertNotEqual(FileManager.default.currentDirectoryPath, folder.path) - - XCTAssertTrue(FileManager.default.changeCurrentDirectoryPath(folder.path)) - let file = try File(path: "file") - try XCTAssertEqual(file.read(), Data()) - } - } - - func testReadingFileWithTildePath() { - performTest { - try folder.createFile(named: "File") - let file = try File(path: "~/.filesTest/File") - try XCTAssertEqual(file.read(), Data()) - XCTAssertEqual(file.path, folder.path + "File") - - // Cleanup since we're performing a test in the actual home folder - try file.delete() - } - } - - func testReadingFileFromCurrentFoldersParent() { - performTest { - let subfolder = try folder.createSubfolder(named: "folder") - let file = try folder.createFile(named: "file") - - // Move to the subfolder - XCTAssertNotEqual(FileManager.default.currentDirectoryPath, subfolder.path) - XCTAssertTrue(FileManager.default.changeCurrentDirectoryPath(subfolder.path)) - - try XCTAssertEqual(File(path: "../file"), file) - } - } - - func testReadingFileWithMultipleParentReferencesWithinPath() { - performTest { - let subfolderA = try folder.createSubfolder(named: "A") - try folder.createSubfolder(named: "B") - let subfolderC = try folder.createSubfolder(named: "C") - let file = try subfolderC.createFile(named: "file") - - try XCTAssertEqual(File(path: subfolderA.path + "../B/../C/file"), file) - } - } - - func testRenamingFolder() { - performTest { - let subfolder = try folder.createSubfolder(named: "folder") - try subfolder.rename(to: "renamedFolder") - XCTAssertEqual(subfolder.name, "renamedFolder") - XCTAssertEqual(subfolder.path, folder.path + "renamedFolder/") - } - } - - func testAccesingFileByPath() { - performTest { - let subfolderA = try folder.createSubfolder(named: "A") - let subfolderB = try subfolderA.createSubfolder(named: "B") - let file = try subfolderB.createFile(named: "C") - try XCTAssertEqual(folder.file(at: "A/B/C"), file) - } - } - - func testAccessingSubfolderByPath() { - performTest { - let subfolderA = try folder.createSubfolder(named: "A") - let subfolderB = try subfolderA.createSubfolder(named: "B") - let subfolderC = try subfolderB.createSubfolder(named: "C") - try XCTAssertEqual(folder.subfolder(at: "A/B/C"), subfolderC) - } - } - - func testEmptyingFolder() { - performTest { - try folder.createFile(named: "A") - try folder.createFile(named: "B") - XCTAssertEqual(folder.files.count(), 2) - - try folder.empty() - XCTAssertEqual(folder.files.count(), 0) - } - } - - func testEmptyingFolderWithHiddenFiles() { - performTest { - let subfolder = try folder.createSubfolder(named: "folder") - - try subfolder.createFile(named: "A") - try subfolder.createFile(named: ".B") - XCTAssertEqual(subfolder.files.includingHidden.count(), 2) - - // Per default, hidden files should not be deleted - try subfolder.empty() - XCTAssertEqual(subfolder.files.includingHidden.count(), 1) - - try subfolder.empty(includingHidden: true) - XCTAssertEqual(folder.files.count(), 0) - } - } - - func testCheckingEmptyFolders() { - performTest { - let emptySubfolder = try folder.createSubfolder(named: "1") - XCTAssertTrue(emptySubfolder.isEmpty()) - - let subfolderWithFile = try folder.createSubfolder(named: "2") - try subfolderWithFile.createFile(named: "A") - XCTAssertFalse(subfolderWithFile.isEmpty()) - - let subfolderWithHiddenFile = try folder.createSubfolder(named: "3") - try subfolderWithHiddenFile.createFile(named: ".B") - XCTAssertTrue(subfolderWithHiddenFile.isEmpty()) - XCTAssertFalse(subfolderWithHiddenFile.isEmpty(includingHidden: true)) - - let subfolderWithFolder = try folder.createSubfolder(named: "3") - try subfolderWithFolder.createSubfolder(named: "4") - XCTAssertFalse(subfolderWithFile.isEmpty()) - } - } - - func testMovingFiles() { - performTest { - try folder.createFile(named: "A") - try folder.createFile(named: "B") - XCTAssertEqual(folder.files.count(), 2) - - let subfolder = try folder.createSubfolder(named: "folder") - try folder.files.move(to: subfolder) - try XCTAssertNotNil(subfolder.file(named: "A")) - try XCTAssertNotNil(subfolder.file(named: "B")) - XCTAssertEqual(folder.files.count(), 0) - } - } - - func testCopyingFiles() { - performTest { - let file = try folder.createFile(named: "A") - try file.write("content") - - let subfolder = try folder.createSubfolder(named: "folder") - let copiedFile = try file.copy(to: subfolder) - try XCTAssertNotNil(folder.file(named: "A")) - try XCTAssertNotNil(subfolder.file(named: "A")) - try XCTAssertEqual(file.read(), subfolder.file(named: "A").read()) - try XCTAssertEqual(copiedFile, subfolder.file(named: "A")) - XCTAssertEqual(folder.files.count(), 1) - } - } - - func testMovingFolders() { - performTest { - let a = try folder.createSubfolder(named: "A") - let b = try a.createSubfolder(named: "B") - _ = try b.createSubfolder(named: "C") - - try b.move(to: folder) - XCTAssertTrue(folder.containsSubfolder(named: "B")) - XCTAssertTrue(b.containsSubfolder(named: "C")) - } - } - - func testCopyingFolders() { - performTest { - let copyingFolder = try folder.createSubfolder(named: "A") - - let subfolder = try folder.createSubfolder(named: "folder") - let copiedFolder = try copyingFolder.copy(to: subfolder) - XCTAssertTrue(folder.containsSubfolder(named: "A")) - XCTAssertTrue(subfolder.containsSubfolder(named: "A")) - XCTAssertEqual(copiedFolder, try subfolder.subfolder(named: "A")) - XCTAssertEqual(folder.subfolders.count(), 2) - XCTAssertEqual(subfolder.subfolders.count(), 1) - } - } - - func testEnumeratingFiles() { - performTest { - try folder.createFile(named: "1") - try folder.createFile(named: "2") - try folder.createFile(named: "3") - - // Hidden files should be excluded by default - try folder.createFile(named: ".hidden") - - XCTAssertEqual(folder.files.names().sorted(), ["1", "2", "3"]) - XCTAssertEqual(folder.files.count(), 3) - } - } - - func testEnumeratingFilesIncludingHidden() { - performTest { - let subfolder = try folder.createSubfolder(named: "folder") - try subfolder.createFile(named: ".hidden") - try subfolder.createFile(named: "visible") - - let files = subfolder.files.includingHidden - XCTAssertEqual(files.names().sorted(), [".hidden", "visible"]) - XCTAssertEqual(files.count(), 2) - } - } - - func testEnumeratingFilesRecursively() { - performTest { - let subfolder1 = try folder.createSubfolder(named: "1") - let subfolder2 = try folder.createSubfolder(named: "2") - - let subfolder1A = try subfolder1.createSubfolder(named: "A") - let subfolder1B = try subfolder1.createSubfolder(named: "B") - - let subfolder2A = try subfolder2.createSubfolder(named: "A") - let subfolder2B = try subfolder2.createSubfolder(named: "B") - - try subfolder1.createFile(named: "File1") - try subfolder1A.createFile(named: "File1A") - try subfolder1B.createFile(named: "File1B") - try subfolder2.createFile(named: "File2") - try subfolder2A.createFile(named: "File2A") - try subfolder2B.createFile(named: "File2B") - - let expectedNames = ["File1", "File1A", "File1B", "File2", "File2A", "File2B"] - let sequence = folder.files.recursive - XCTAssertEqual(sequence.names(), expectedNames) - XCTAssertEqual(sequence.count(), 6) - } - } - - func testEnumeratingSubfolders() { - performTest { - try folder.createSubfolder(named: "1") - try folder.createSubfolder(named: "2") - try folder.createSubfolder(named: "3") - - XCTAssertEqual(folder.subfolders.names(), ["1", "2", "3"]) - XCTAssertEqual(folder.subfolders.count(), 3) - } - } - - func testEnumeratingSubfoldersRecursively() { - performTest { - let subfolder1 = try folder.createSubfolder(named: "1") - let subfolder2 = try folder.createSubfolder(named: "2") - - try subfolder1.createSubfolder(named: "1A") - try subfolder1.createSubfolder(named: "1B") - - try subfolder2.createSubfolder(named: "2A") - try subfolder2.createSubfolder(named: "2B") - - let expectedNames = ["1", "1A", "1B", "2", "2A", "2B"] - let sequence = folder.subfolders.recursive - XCTAssertEqual(sequence.names().sorted(), expectedNames) - XCTAssertEqual(sequence.count(), 6) - } - } - - func testRenamingFoldersWhileEnumeratingSubfoldersRecursively() { - performTest { - let subfolder1 = try folder.createSubfolder(named: "1") - let subfolder2 = try folder.createSubfolder(named: "2") - - try subfolder1.createSubfolder(named: "1A") - try subfolder1.createSubfolder(named: "1B") - - try subfolder2.createSubfolder(named: "2A") - try subfolder2.createSubfolder(named: "2B") - - let sequence = folder.subfolders.recursive - for folder in sequence { - try folder.rename(to: "Folder " + folder.name) - } - - let expectedNames = ["Folder 1", "Folder 1A", "Folder 1B", "Folder 2", "Folder 2A", "Folder 2B"] - - XCTAssertEqual(sequence.names().sorted(), expectedNames) - XCTAssertEqual(sequence.count(), 6) - } - } - - func testFirstAndLastInFileSequence() { - performTest { - try folder.createFile(named: "A") - try folder.createFile(named: "B") - try folder.createFile(named: "C") - - XCTAssertEqual(folder.files.first?.name, "A") - XCTAssertEqual(folder.files.last()?.name, "C") - } - } - - func testConvertingFileSequenceToRecursive() { - performTest { - try folder.createFile(named: "A") - try folder.createFile(named: "B") - - let subfolder = try folder.createSubfolder(named: "1") - try subfolder.createFile(named: "1A") - - let names = folder.files.recursive.names() - XCTAssertEqual(names, ["A", "B", "1A"]) - } - } - - func testModificationDate() { - performTest { - let subfolder = try folder.createSubfolder(named: "Folder") - XCTAssertTrue(subfolder.modificationDate.map(Calendar.current.isDateInToday) ?? false) - - let file = try folder.createFile(named: "File") - XCTAssertTrue(file.modificationDate.map(Calendar.current.isDateInToday) ?? false) - } - } - - func testParent() { - performTest { - try XCTAssertEqual(folder.createFile(named: "test").parent, folder) - - let subfolder = try folder.createSubfolder(named: "subfolder") - XCTAssertEqual(subfolder.parent, folder) - try XCTAssertEqual(subfolder.createFile(named: "test").parent, subfolder) - } - } - - func testRootFolderParentIsNil() { - performTest { - try XCTAssertNil(Folder(path: "/").parent) - } - } - - func testRootSubfolderParentIsRoot() { - performTest { - let rootFolder = try Folder(path: "/") - let subfolder = rootFolder.subfolders.first - XCTAssertEqual(subfolder?.parent, rootFolder) - } - } - - func testOpeningFileWithEmptyPathThrows() { - performTest { - try assert(File(path: ""), throwsErrorOfType: LocationError.self) - } - } - - func testDeletingNonExistingFileThrows() { - performTest { - let file = try folder.createFile(named: "file") - try file.delete() - try assert(file.delete(), throwsErrorOfType: LocationError.self) - } - } - - func testWritingDataToFile() { - performTest { - let file = try folder.createFile(named: "file") - try XCTAssertEqual(file.read(), Data()) - - let data = "New content".data(using: .utf8)! - try file.write(data) - try XCTAssertEqual(file.read(), data) - } - } - - func testWritingStringToFile() { - performTest { - let file = try folder.createFile(named: "file") - try XCTAssertEqual(file.read(), Data()) - - try file.write("New content") - try XCTAssertEqual(file.read(), "New content".data(using: .utf8)) - } - } - - func testAppendingDataToFile() { - performTest { - let file = try folder.createFile(named: "file") - let data = "Old content\n".data(using: .utf8)! - try file.write(data) - - let newData = "I'm the appended content 💯\n".data(using: .utf8)! - try file.append(newData) - try XCTAssertEqual(file.read(), "Old content\nI'm the appended content 💯\n".data(using: .utf8)) - } - } - - func testAppendingStringToFile() { - performTest { - let file = try folder.createFile(named: "file") - try file.write("Old content\n") - - let newString = "I'm the appended content 💯\n" - try file.append(newString) - try XCTAssertEqual(file.read(), "Old content\nI'm the appended content 💯\n".data(using: .utf8)) - } - } - - func testFileDescription() { - performTest { - let file = try folder.createFile(named: "file") - XCTAssertEqual(file.description, "File(name: file, path: \(folder.path)file)") - } - } - - func testFolderDescription() { - performTest { - let subfolder = try folder.createSubfolder(named: "folder") - XCTAssertEqual(subfolder.description, "Folder(name: folder, path: \(folder.path)folder/)") - } - } - - func testFilesDescription() { - performTest { - let fileA = try folder.createFile(named: "fileA") - let fileB = try folder.createFile(named: "fileB") - XCTAssertEqual(folder.files.description, "\(fileA.description)\n\(fileB.description)") - } - } - - func testSubfoldersDescription() { - performTest { - let folderA = try folder.createSubfolder(named: "folderA") - let folderB = try folder.createSubfolder(named: "folderB") - XCTAssertEqual(folder.subfolders.description, "\(folderA.description)\n\(folderB.description)") - } - } - - func testMovingFolderContents() { - performTest { - let parentFolder = try folder.createSubfolder(named: "parentA") - try parentFolder.createSubfolder(named: "folderA") - try parentFolder.createSubfolder(named: "folderB") - try parentFolder.createFile(named: "fileA") - try parentFolder.createFile(named: "fileB") - - XCTAssertEqual(parentFolder.subfolders.names(), ["folderA", "folderB"]) - XCTAssertEqual(parentFolder.files.names(), ["fileA", "fileB"]) - - let newParentFolder = try folder.createSubfolder(named: "parentB") - try parentFolder.moveContents(to: newParentFolder) - - XCTAssertEqual(parentFolder.subfolders.names(), []) - XCTAssertEqual(parentFolder.files.names(), []) - XCTAssertEqual(newParentFolder.subfolders.names(), ["folderA", "folderB"]) - XCTAssertEqual(newParentFolder.files.names(), ["fileA", "fileB"]) - } - } - - func testMovingFolderHiddenContents() { - performTest { - let parentFolder = try folder.createSubfolder(named: "parent") - try parentFolder.createFile(named: ".hidden") - try parentFolder.createSubfolder(named: ".folder") - - XCTAssertEqual(parentFolder.files.includingHidden.names(), [".hidden"]) - XCTAssertEqual(parentFolder.subfolders.includingHidden.names(), [".folder"]) - - let newParentFolder = try folder.createSubfolder(named: "parentB") - try parentFolder.moveContents(to: newParentFolder, includeHidden: true) - - XCTAssertEqual(parentFolder.files.includingHidden.names(), []) - XCTAssertEqual(parentFolder.subfolders.includingHidden.names(), []) - XCTAssertEqual(newParentFolder.files.includingHidden.names(), [".hidden"]) - XCTAssertEqual(newParentFolder.subfolders.includingHidden.names(), [".folder"]) - } - } - - func testAccessingHomeFolder() { - XCTAssertNotNil(Folder.home) - } - - func testAccessingCurrentWorkingDirectory() { - performTest { - let folder = try Folder(path: "") - XCTAssertEqual(FileManager.default.currentDirectoryPath + "/", folder.path) - XCTAssertEqual(Folder.current, folder) - } - } - - func testNameExcludingExtensionWithLongFileName() { - performTest { - let file = try folder.createFile(named: "AVeryLongFileName.png") - XCTAssertEqual(file.nameExcludingExtension, "AVeryLongFileName") - } - } - - func testNameExcludingExtensionWithoutExtension() { - performTest { - let file = try folder.createFile(named: "File") - let subfolder = try folder.createSubfolder(named: "Subfolder") - - XCTAssertEqual(file.nameExcludingExtension, "File") - XCTAssertEqual(subfolder.nameExcludingExtension, "Subfolder") - } - } - - func testRelativePaths() { - performTest { - let file = try folder.createFile(named: "FileA") - let subfolder = try folder.createSubfolder(named: "Folder") - let fileInSubfolder = try subfolder.createFile(named: "FileB") - - XCTAssertEqual(file.path(relativeTo: folder), "FileA") - XCTAssertEqual(subfolder.path(relativeTo: folder), "Folder") - XCTAssertEqual(fileInSubfolder.path(relativeTo: folder), "Folder/FileB") - } - } - - func testRelativePathIsAbsolutePathForNonParent() { - performTest { - let file = try folder.createFile(named: "FileA") - let subfolder = try folder.createSubfolder(named: "Folder") - - XCTAssertEqual(file.path(relativeTo: subfolder), file.path) - } - } - - func testCreateFileIfNeeded() { - performTest { - let fileA = try folder.createFileIfNeeded(withName: "file", contents: "Hello".data(using: .utf8)!) - let fileB = try folder.createFileIfNeeded(withName: "file", contents: "World".data(using: .utf8)!) - try XCTAssertEqual(fileA.readAsString(), "Hello") - try XCTAssertEqual(fileA.read(), fileB.read()) - } - } - - func testCreateFolderIfNeeded() { - performTest { - let subfolderA = try folder.createSubfolderIfNeeded(withName: "Subfolder") - try subfolderA.createFile(named: "file") - let subfolderB = try folder.createSubfolderIfNeeded(withName: subfolderA.name) - XCTAssertEqual(subfolderA, subfolderB) - XCTAssertEqual(subfolderA.files.count(), subfolderB.files.count()) - XCTAssertEqual(subfolderA.files.first, subfolderB.files.first) - } - } - - func testCreateSubfolderIfNeeded() { - performTest { - let subfolderA = try folder.createSubfolderIfNeeded(withName: "folder") - try subfolderA.createFile(named: "file") - let subfolderB = try folder.createSubfolderIfNeeded(withName: "folder") - XCTAssertEqual(subfolderA, subfolderB) - XCTAssertEqual(subfolderA.files.count(), subfolderB.files.count()) - XCTAssertEqual(subfolderA.files.first, subfolderB.files.first) - } - } - - func testCreatingFileWithString() { - performTest { - let file = try folder.createFile(named: "file", contents: Data("Hello world".utf8)) - XCTAssertEqual(try file.readAsString(), "Hello world") - } - } - - func testUsingCustomFileManager() { - class FileManagerMock: FileManager { - var noFilesExist = false - - override func fileExists(atPath path: String, isDirectory: UnsafeMutablePointer?) -> Bool { - if noFilesExist { - return false - } - - return super.fileExists(atPath: path, isDirectory: isDirectory) - } - } - - performTest { - let fileManager = FileManagerMock() - let subfolder = try folder.managedBy(fileManager).createSubfolder(named: UUID().uuidString) - let file = try subfolder.createFile(named: "file") - try XCTAssertEqual(file.read(), Data()) - - // Mock that no files exist, which should call file lookups to fail - fileManager.noFilesExist = true - try assert(subfolder.file(named: "file"), throwsErrorOfType: LocationError.self) - } - } - - func testFolderContainsFile() { - performTest { - let subfolder = try folder.createSubfolder(named: "subfolder") - let fileA = try subfolder.createFile(named: "A") - XCTAssertFalse(folder.contains(fileA)) - - let fileB = try folder.createFile(named: "B") - XCTAssertTrue(folder.contains(fileB)) - } - } - - func testFolderContainsSubfolder() { - performTest { - let subfolder = try folder.createSubfolder(named: "subfolder") - let subfolderA = try subfolder.createSubfolder(named: "A") - XCTAssertFalse(folder.contains(subfolderA)) - - let subfolderB = try folder.createSubfolder(named: "B") - XCTAssertTrue(folder.contains(subfolderB)) - } - } - - func testErrorDescriptions() { - let missingError = FilesError( - path: "/some/path", - reason: LocationErrorReason.missing - ) - - XCTAssertEqual(missingError.description, """ - Files encountered an error at '/some/path'. - Reason: missing - """) - - let encodingError = FilesError( - path: "/some/path", - reason: WriteErrorReason.stringEncodingFailed("Hello") - ) - - XCTAssertEqual(encodingError.description, """ - Files encountered an error at '/some/path'. - Reason: stringEncodingFailed(\"Hello\") - """) - } - - // MARK: - Utilities - - private func performTest(closure: () throws -> Void) { - do { - try folder.empty() - try closure() - } catch { - XCTFail("Unexpected error thrown: \(error)") - } - } - - private func assert(_ expression: @autoclosure () throws -> T, - throwsErrorOfType expectedError: E.Type) { - do { - _ = try expression() - XCTFail("Expected error to be thrown") - } catch { - XCTAssertTrue(error is E) - } - } - - // MARK: - Linux - - static var allTests = [ - ("testCreatingAndDeletingFile", testCreatingAndDeletingFile), - ("testCreatingFileAtPath", testCreatingFileAtPath), - ("testDroppingLeadingSlashWhenCreatingFileAtPath", testDroppingLeadingSlashWhenCreatingFileAtPath), - ("testCreatingAndDeletingFolder", testCreatingAndDeletingFolder), - ("testCreatingSubfolderAtPath", testCreatingSubfolderAtPath), - ("testDroppingLeadingSlashWhenCreatingSubfolderAtPath", testDroppingLeadingSlashWhenCreatingSubfolderAtPath), - ("testReadingFileAsString", testReadingFileAsString), - ("testReadingFileAsInt", testReadingFileAsInt), - ("testRenamingFile", testRenamingFile), - ("testRenamingFileWithNameIncludingExtension", testRenamingFileWithNameIncludingExtension), - ("testReadingFileWithRelativePath", testReadingFileWithRelativePath), - ("testReadingFileWithTildePath", testReadingFileWithTildePath), - ("testReadingFileFromCurrentFoldersParent", testReadingFileFromCurrentFoldersParent), - ("testReadingFileWithMultipleParentReferencesWithinPath", testReadingFileWithMultipleParentReferencesWithinPath), - ("testRenamingFolder", testRenamingFolder), - ("testAccesingFileByPath", testAccesingFileByPath), - ("testAccessingSubfolderByPath", testAccessingSubfolderByPath), - ("testEmptyingFolder", testEmptyingFolder), - ("testEmptyingFolderWithHiddenFiles", testEmptyingFolderWithHiddenFiles), - ("testCheckingEmptyFolders", testCheckingEmptyFolders), - ("testMovingFiles", testMovingFiles), - ("testCopyingFiles", testCopyingFiles), - ("testCopyingFolders", testCopyingFolders), - ("testEnumeratingFiles", testEnumeratingFiles), - ("testEnumeratingFilesIncludingHidden", testEnumeratingFilesIncludingHidden), - ("testEnumeratingFilesRecursively", testEnumeratingFilesRecursively), - ("testEnumeratingSubfolders", testEnumeratingSubfolders), - ("testEnumeratingSubfoldersRecursively", testEnumeratingSubfoldersRecursively), - ("testRenamingFoldersWhileEnumeratingSubfoldersRecursively", testRenamingFoldersWhileEnumeratingSubfoldersRecursively), - ("testFirstAndLastInFileSequence", testFirstAndLastInFileSequence), - ("testConvertingFileSequenceToRecursive", testConvertingFileSequenceToRecursive), - ("testModificationDate", testModificationDate), - ("testParent", testParent), - ("testRootFolderParentIsNil", testRootFolderParentIsNil), - ("testRootSubfolderParentIsRoot", testRootSubfolderParentIsRoot), - ("testOpeningFileWithEmptyPathThrows", testOpeningFileWithEmptyPathThrows), - ("testDeletingNonExistingFileThrows", testDeletingNonExistingFileThrows), - ("testWritingDataToFile", testWritingDataToFile), - ("testWritingStringToFile", testWritingStringToFile), - ("testAppendingDataToFile", testAppendingDataToFile), - ("testAppendingStringToFile", testAppendingStringToFile), - ("testFileDescription", testFileDescription), - ("testFolderDescription", testFolderDescription), - ("testFilesDescription", testFilesDescription), - ("testSubfoldersDescription", testSubfoldersDescription), - ("testMovingFolderContents", testMovingFolderContents), - ("testMovingFolderHiddenContents", testMovingFolderHiddenContents), - ("testAccessingHomeFolder", testAccessingHomeFolder), - ("testAccessingCurrentWorkingDirectory", testAccessingCurrentWorkingDirectory), - ("testNameExcludingExtensionWithLongFileName", testNameExcludingExtensionWithLongFileName), - ("testRelativePaths", testRelativePaths), - ("testRelativePathIsAbsolutePathForNonParent", testRelativePathIsAbsolutePathForNonParent), - ("testCreateFileIfNeeded", testCreateFileIfNeeded), - ("testCreateFolderIfNeeded", testCreateFolderIfNeeded), - ("testCreateSubfolderIfNeeded", testCreateSubfolderIfNeeded), - ("testCreatingFileWithString", testCreatingFileWithString), - ("testUsingCustomFileManager", testUsingCustomFileManager), - ("testFolderContainsFile", testFolderContainsFile), - ("testFolderContainsSubfolder", testFolderContainsSubfolder), - ("testErrorDescriptions", testErrorDescriptions) - ] -} - -#if os(macOS) -extension FilesTests { - func testAccessingDocumentsFolder() { - XCTAssertNotNil(Folder.documents, "Documents folder should be available.") - } -} -#endif - -#if os(iOS) || os(tvOS) || os(macOS) -extension FilesTests { - func testAccessingLibraryFolder() { - XCTAssertNotNil(Folder.library, "Library folder should be available.") - } - - func testResolvingFolderMatchingSearchPath() { - performTest { - // Real file I/O - XCTAssertNotNil(try Folder.matching(.cachesDirectory)) - XCTAssertNotNil(try Folder.matching(.libraryDirectory)) - - // Mocked file I/O - final class FileManagerMock: FileManager { - var target: Folder? - - override func urls( - for directory: FileManager.SearchPathDirectory, - in domainMask: FileManager.SearchPathDomainMask - ) -> [URL] { - return target.map { [$0.url] } ?? [] - } - } - - let target = try folder.createSubfolder(named: "Target") - - let fileManager = FileManagerMock() - fileManager.target = target - - let resolved = try Folder.matching(.documentDirectory, - resolvedBy: fileManager - ) - - XCTAssertEqual(resolved, target) - } - } +internal class FilesTests: XCTestCase { + // MARK: - Linux + + internal static let allTests = [ + ("testCreatingAndDeletingFile", testCreatingAndDeletingFile), + ("testCreatingFileAtPath", testCreatingFileAtPath), + ( + "testDroppingLeadingSlashWhenCreatingFileAtPath", + testDroppingLeadingSlashWhenCreatingFileAtPath + ), + ("testCreatingAndDeletingFolder", testCreatingAndDeletingFolder), + ("testCreatingSubfolderAtPath", testCreatingSubfolderAtPath), + ( + "testDroppingLeadingSlashWhenCreatingSubfolderAtPath", + testDroppingLeadingSlashWhenCreatingSubfolderAtPath + ), + ("testReadingFileAsString", testReadingFileAsString), + ("testReadingFileAsInt", testReadingFileAsInt), + ("testRenamingFile", testRenamingFile), + ("testRenamingFileWithNameIncludingExtension", testRenamingFileWithNameIncludingExtension), + ("testReadingFileWithRelativePath", testReadingFileWithRelativePath), + ("testReadingFileWithTildePath", testReadingFileWithTildePath), + ("testReadingFileFromCurrentFoldersParent", testReadingFileFromCurrentFoldersParent), + ( + "testReadingFileWithMultipleParentReferencesWithinPath", + testReadingFileWithMultipleParentReferencesWithinPath + ), + ("testRenamingFolder", testRenamingFolder), + ("testAccesingFileByPath", testAccesingFileByPath), + ("testAccessingSubfolderByPath", testAccessingSubfolderByPath), + ("testEmptyingFolder", testEmptyingFolder), + ("testEmptyingFolderWithHiddenFiles", testEmptyingFolderWithHiddenFiles), + ("testCheckingEmptyFolders", testCheckingEmptyFolders), + ("testMovingFiles", testMovingFiles), + ("testCopyingFiles", testCopyingFiles), + ("testCopyingFolders", testCopyingFolders), + ("testEnumeratingFiles", testEnumeratingFiles), + ("testEnumeratingFilesIncludingHidden", testEnumeratingFilesIncludingHidden), + ("testEnumeratingFilesRecursively", testEnumeratingFilesRecursively), + ("testEnumeratingSubfolders", testEnumeratingSubfolders), + ("testEnumeratingSubfoldersRecursively", testEnumeratingSubfoldersRecursively), + ( + "testRenamingFoldersWhileEnumeratingSubfoldersRecursively", + testRenamingFoldersWhileEnumeratingSubfoldersRecursively + ), + ("testFirstAndLastInFileSequence", testFirstAndLastInFileSequence), + ("testConvertingFileSequenceToRecursive", testConvertingFileSequenceToRecursive), + ("testModificationDate", testModificationDate), + ("testParent", testParent), + ("testRootFolderParentIsNil", testRootFolderParentIsNil), + ("testRootSubfolderParentIsRoot", testRootSubfolderParentIsRoot), + ("testOpeningFileWithEmptyPathThrows", testOpeningFileWithEmptyPathThrows), + ("testDeletingNonExistingFileThrows", testDeletingNonExistingFileThrows), + ("testWritingDataToFile", testWritingDataToFile), + ("testWritingStringToFile", testWritingStringToFile), + ("testAppendingDataToFile", testAppendingDataToFile), + ("testAppendingStringToFile", testAppendingStringToFile), + ("testFileDescription", testFileDescription), + ("testFolderDescription", testFolderDescription), + ("testFilesDescription", testFilesDescription), + ("testSubfoldersDescription", testSubfoldersDescription), + ("testMovingFolderContents", testMovingFolderContents), + ("testMovingFolderHiddenContents", testMovingFolderHiddenContents), + ("testAccessingHomeFolder", testAccessingHomeFolder), + ("testAccessingCurrentWorkingDirectory", testAccessingCurrentWorkingDirectory), + ("testNameExcludingExtensionWithLongFileName", testNameExcludingExtensionWithLongFileName), + ("testRelativePaths", testRelativePaths), + ("testRelativePathIsAbsolutePathForNonParent", testRelativePathIsAbsolutePathForNonParent), + ("testCreateFileIfNeeded", testCreateFileIfNeeded), + ("testCreateFolderIfNeeded", testCreateFolderIfNeeded), + ("testCreateSubfolderIfNeeded", testCreateSubfolderIfNeeded), + ("testCreatingFileWithString", testCreatingFileWithString), + ("testFolderContainsFile", testFolderContainsFile), + ("testFolderContainsSubfolder", testFolderContainsSubfolder), + ("testErrorDescriptions", testErrorDescriptions), + ] + + /// The canonical filesystem root for the current platform (`/`, or a volume + /// root such as `C:/` on Windows). + internal static var rootPath: String { + #if os(Windows) + let cwd = FileManager.default.currentDirectoryPath.replacingOccurrences(of: "\\", with: "/") + if let colon = cwd.firstIndex(of: ":") { + return String(cwd[...colon]) + "/" + } + return "/" + #else + return "/" + #endif + } + + // MARK: - Fixtures + + // The test folder is created in `setUp` before every test runs, so this + // implicitly unwrapped optional is always populated while tests execute. + // swiftlint:disable:next implicitly_unwrapped_optional + internal var folder: Folder! + + private var originalWorkingDirectoryPath = "" + + // MARK: - XCTestCase + + override internal func setUp() { + super.setUp() + originalWorkingDirectoryPath = FileManager.default.currentDirectoryPath + // Test scaffolding: the shared test folder must exist for every test, so + // failing fast here is the desired behavior. + // swiftlint:disable:next force_try + folder = try! Folder.home.createSubfolderIfNeeded(withName: ".filesTest") + // swiftlint:disable:next force_try + try! folder.empty() + } + + override internal func tearDown() { + // Some relative-path tests change the process working directory into the + // fixture. Windows refuses to delete a directory that is still the current + // working directory, so restore it before removing the fixture. + XCTAssertTrue(FileManager.default.changeCurrentDirectoryPath(originalWorkingDirectoryPath)) + try? folder.delete() + super.tearDown() + } + + // MARK: - Utilities + + internal func performTest(closure: () throws -> Void) { + do { + try folder.empty() + try closure() + } catch { + XCTFail("Unexpected error thrown: \(error)") + } + } + + internal func assert( + _ expression: @autoclosure () throws -> T, + throwsErrorOfType expectedError: E.Type + ) { + do { + _ = try expression() + XCTFail("Expected error to be thrown") + } catch { + XCTAssertTrue(error is E) + } + } + + // MARK: - Platform helpers + + /// The given path in the library's canonical (forward-slash) form. + /// + /// Mirrors `Files`' own (internal) canonicalization so assertions can express + /// expected paths without scattering `#if os(Windows)` through the test bodies. + internal func canonical(_ path: String) -> String { + #if os(Windows) + return path.replacingOccurrences(of: "\\", with: "/") + #else + return path + #endif + } } -#endif diff --git a/Tests/FilesTests/PathTests.swift b/Tests/FilesTests/PathTests.swift new file mode 100644 index 0000000..fbda6ed --- /dev/null +++ b/Tests/FilesTests/PathTests.swift @@ -0,0 +1,109 @@ +// +// PathTests.swift +// +// Copyright (c) 2017-2019 John Sundell. +// Licensed under the MIT license, as follows: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation +import XCTest + +@testable import Files + +// Filesystem-free coverage for the internal path helpers, so the separator and +// parent-derivation string math is exercised on every platform (including the +// Windows-only branches, via synthetic `C:/…` inputs) without needing a Windows +// runner. +internal final class PathTests: XCTestCase { + internal func testCanonicalNativeRoundTripIsIdempotent() { + let paths = [ + "/Users/example/.filesTest/", + "C:/Users/example/.filesTest/", + "relative/child/leaf", + "/", + ] + + for path in paths { + XCTAssertEqual(path.canonicalizedPath.canonicalizedPath, path.canonicalizedPath) + XCTAssertEqual(path.nativePath.nativePath, path.nativePath) + // Converting to native and back to canonical is a no-op on canonical input. + XCTAssertEqual(path.canonicalizedPath.nativePath.canonicalizedPath, path.canonicalizedPath) + } + } + + internal func testCanonicalizedPathHandlesSeparatorsPerPlatform() { + // `canonicalizedPath` rewrites native separators only on Windows; elsewhere + // it is the identity (so macOS/Linux behavior is provably unchanged). + #if os(Windows) + XCTAssertFalse("C:\\Users\\example".canonicalizedPath.contains("\\")) + XCTAssertEqual("C:\\Users\\example".canonicalizedPath, "C:/Users/example") + #else + XCTAssertEqual("C:\\Users\\example".canonicalizedPath, "C:\\Users\\example") + #endif + XCTAssertEqual("plain/forward/slash".canonicalizedPath, "plain/forward/slash") + } + + internal func testMakeParentPathForPOSIXPaths() { + // `URL(fileURLWithPath:)` strips a trailing slash, so the parent of a + // folder `…/example/folder/` is `…/example/` (the folder's containing dir). + XCTAssertEqual(makeParentPath(for: "/Users/example/file"), "/Users/example/") + XCTAssertEqual(makeParentPath(for: "/Users/example/folder/"), "/Users/example/") + XCTAssertEqual(makeParentPath(for: "/Users/file"), "/Users/") + XCTAssertEqual(makeParentPath(for: "/file"), "/") + XCTAssertNil(makeParentPath(for: "/")) + } + + internal func testMakeParentPathPreservesWindowsDrivePrefix() { + // These synthetic canonical Windows paths exercise the drive-prefix split and + // rejoin on every platform, so the logic is validated off Windows too. The + // rejoined result keeps the `C:` prefix rather than collapsing to a POSIX `/…`. + XCTAssertEqual(makeParentPath(for: "C:/Users/example/folder/"), "C:/Users/example/") + XCTAssertEqual(makeParentPath(for: "C:/Users/file"), "C:/Users/") + XCTAssertEqual(makeParentPath(for: "C:/file"), "C:/") + } + + internal func testMakeParentPathTreatsEmptyPathAsCurrentDirectory() { + XCTAssertEqual( + makeParentPath(for: ""), + makeParentPath(for: FileManager.default.currentDirectoryPath) + ) + } + + internal func testMakeParentPathAtDriveRootIsNilOnWindows() { + // The drive-root guard is Windows-only (a drive root has no parent); off + // Windows `C:/` is just an ordinary relative-looking path. + #if os(Windows) + XCTAssertNil(makeParentPath(for: "C:/")) + XCTAssertNil(makeParentPath(for: "C:")) + #endif + } + + internal func testIsDriveRoot() { + #if os(Windows) + XCTAssertTrue("C:/".isDriveRoot) + XCTAssertTrue("C:".isDriveRoot) + XCTAssertFalse("C:/Users".isDriveRoot) + #else + XCTAssertFalse("C:/".isDriveRoot) + XCTAssertFalse("/".isDriveRoot) + #endif + } +} diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift index e176da4..350a68d 100644 --- a/Tests/LinuxMain.swift +++ b/Tests/LinuxMain.swift @@ -1,6 +1,7 @@ import XCTest + @testable import FilesTests XCTMain([ - testCase(FilesTests.allTests), + testCase(FilesTests.allTests) ]) diff --git a/buddybuild_postbuild.sh b/buddybuild_postbuild.sh deleted file mode 100755 index 2544c08..0000000 --- a/buddybuild_postbuild.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -# Run tests using SPM for macOS -swift test - -# Run tests for tvOS -xcodebuild clean test -quiet -project Files.xcodeproj -scheme Files-tvOS -destination "platform=tvOS Simulator,name=Apple TV" CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO ONLY_ACTIVE_ARCH=NO diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..eb165f7 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,2 @@ +ignore: + - "Tests/**/*"