diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index da4236d..741b4f8 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -2,9 +2,6 @@ on: push: branches: - master - paths: - - '**/*.swift' - - .github/workflows/checks.yml jobs: smoke: runs-on: ubuntu-latest diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce494ba..87b0086 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,47 +1,34 @@ name: CI on: pull_request: - paths: - - '**/*.swift' - - .github/workflows/ci.yml - schedule: - - cron: '3 3 * * 5' # 3:03 AM, every Friday concurrency: group: ${{ github.head_ref || 'push' }} cancel-in-progress: true jobs: - verify-linuxmain: - runs-on: macos-10.15 - steps: - - uses: actions/checkout@v2 - - run: swift test --generate-linuxmain - - run: git diff --exit-code - + # we want to test more macOS versions but GitHub make that difficult without + # constant maintenance because runners are deprecated and removed apple: - runs-on: ${{ matrix.os }} + runs-on: macos-latest strategy: matrix: - os: - - macos-10.15 - - macos-11 platform: - iOS - tvOS - macOS - watchOS steps: - - uses: actions/checkout@v2 - - uses: mxcl/xcodebuild@v1 + - uses: actions/checkout@v6 + - uses: mxcl/xcodebuild@latest with: platform: ${{ matrix.platform }} code-coverage: true - warnings-as-errors: true - - uses: codecov/codecov-action@v1 + - uses: codecov/codecov-action@v5 linux: runs-on: ubuntu-latest + continue-on-error: true strategy: matrix: swift: @@ -51,26 +38,37 @@ jobs: - swift:5.2 - swift:5.3 - swift:5.4 - - swiftlang/swift:nightly-5.5 + - swift:5.5 + - swift:5.6 + - swift:5.7 + - swift:5.8 + - swift:5.9 + - swift:5.10 + # - swift:6.0 strangely fails + - swift:6.1 + - swift:6.2 container: image: ${{ matrix.swift }} steps: - - uses: mxcl/get-swift-version@v1 - id: swift + - uses: actions/checkout@v1 - - uses: actions/checkout@v2 + - name: Get Swift version + id: swift + run: | + ver=$(swift --version | head -1 | sed 's/.*Swift version \([0-9]*\).*/\1/') + echo "marketing-version=$ver" >> $GITHUB_OUTPUT - run: useradd -ms /bin/bash mxcl - run: chown -R mxcl . # ^^ we need to be a normal user and not root for the tests to be valid - run: echo ARGS=--enable-code-coverage >> $GITHUB_ENV - if: ${{ steps.swift.outputs.marketing-version > 5 }} + if: ${{ steps.swift.outputs.marketing-version > 6.1 }} - run: su mxcl -c "swift test --parallel $ARGS" - name: Generate `.lcov` - if: ${{ steps.swift.outputs.marketing-version > 5 }} + if: ${{ steps.swift.outputs.marketing-version > 6.1 }} run: | apt-get -qq update && apt-get -qq install curl b=$(swift build --show-bin-path) @@ -82,6 +80,6 @@ jobs: > info.lcov - uses: codecov/codecov-action@v1 - if: ${{ steps.swift.outputs.marketing-version > 5 }} + if: ${{ steps.swift.outputs.marketing-version > 6.1 }} with: file: ./info.lcov diff --git a/Sources/Extensions.swift b/Sources/Extensions.swift index 27f84b8..c0e05f2 100644 --- a/Sources/Extensions.swift +++ b/Sources/Extensions.swift @@ -44,11 +44,22 @@ public extension Bundle { /// Extensions on `String` that work with `Path` rather than `String` or `URL` public extension String { /// Initializes this `String` with the contents of the provided path. + /// - SeeAlso: `init(contentsOf:encoding:)` + @available(macOS, deprecated: 15, message: "Use `init(contentsOf:encoding:)` instead") + @available(iOS, deprecated: 18, message: "Use `init(contentsOf:encoding:)` instead") + @available(tvOS, deprecated: 18, message: "Use `init(contentsOf:encoding:)` instead") + @available(watchOS, deprecated: 11, message: "Use `init(contentsOf:encoding:)` instead") @inlinable init(contentsOf path: P) throws { try self.init(contentsOfFile: path.string) } + /// Initializes this `String` with the contents of the provided path interpreted using a given encoding. + @inlinable + init(contentsOf path: P, encoding: String.Encoding) throws { + try self.init(contentsOfFile: path.string, encoding: encoding) + } + /// - Returns: `to` to allow chaining @inlinable @discardableResult diff --git a/Sources/Path+ls.swift b/Sources/Path+ls.swift index 30462ae..66008b9 100644 --- a/Sources/Path+ls.swift +++ b/Sources/Path+ls.swift @@ -165,23 +165,47 @@ public extension Pathish { //MARK: Directory Listing /** - Same as the `ls` command ∴ output is ”shallow” and unsorted. + Same as the `ls` command ∴ output is "shallow". + - Note: as per `ls`, by default we do *not* return hidden files. Specify `.a` for hidden files. - Parameter options: Configure the listing. - Important: On Linux the listing is always `ls -a` - - WARNING: we actually sort the output :( sorry. Will fix in a major version bump. + - WARNING: ⚠️ **PERFORMANCE**: By default, output is sorted using locale-specific sorting which can be **VERY EXPENSIVE** + for large directories (0.5+ seconds). For better performance, use `.unsorted` or `.aUnsorted` options. + - Note: Sorting will be removed by default in the next major version bump. */ func ls(_ options: ListDirectoryOptions? = nil) -> [Path] { guard let urls = try? FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) else { fputs("warning: could not list: \(self)\n", stderr) return [] } - return urls.compactMap { url in + + let shouldSort: Bool + let includeHidden: Bool + + switch options { + case .some(.a): + shouldSort = true + includeHidden = true + case .some(.aUnsorted): + shouldSort = false + includeHidden = true + case .some(.unsorted): + shouldSort = false + includeHidden = false + case .none: + shouldSort = true + includeHidden = false + } + + let paths = urls.compactMap { url -> Path? in guard let path = Path(url.path) else { return nil } - if options != .a, path.basename().hasPrefix(".") { return nil } - // ^^ we don’t use the Foundation `skipHiddenFiles` because it considers weird things hidden and we are mirroring `ls` + if !includeHidden, path.basename().hasPrefix(".") { return nil } + // ^^ we don't use the Foundation `skipHiddenFiles` because it considers weird things hidden and we are mirroring `ls` return path - }.sorted() + } + + return shouldSort ? paths.sorted() : paths } /// Recursively find files under this path. If the path is a file, no files will be found. @@ -214,7 +238,15 @@ public extension Array where Element == Path { } /// Options for `Path.ls(_:)` +/// +/// - WARNING: Sorting is locale-specific and can be expensive for large directories (0.5+ seconds). +/// Use `.unsorted` or `.aUnsorted` when you don't need sorted output and performance is critical. +/// - Note: In the next major version, sorting will be removed by default. public enum ListDirectoryOptions { - /// Lists hidden files also + /// Lists hidden files also (sorted) case a + /// Lists hidden files also (unsorted for better performance) + case aUnsorted + /// Disables sorting for better performance + case unsorted } diff --git a/Tests/PathTests/PathTests+ls().swift b/Tests/PathTests/PathTests+ls().swift index 58072d3..6d5ac3f 100644 --- a/Tests/PathTests/PathTests+ls().swift +++ b/Tests/PathTests/PathTests+ls().swift @@ -248,4 +248,57 @@ extension PathTests { XCTAssertNil(tmpdir.a.find().next()) } } + + func testLsUnsortedOption() throws { + try Path.mktemp { tmpdir in + // Create files with names that would be sorted differently + try tmpdir.join("zebra.txt").touch() + try tmpdir.join("apple.txt").touch() + try tmpdir.join("banana.txt").touch() + + // Test default (sorted) behavior + let sortedResults = tmpdir.ls() + XCTAssertEqual(sortedResults.count, 3) + XCTAssertEqual(sortedResults[0].basename(), "apple.txt") + XCTAssertEqual(sortedResults[1].basename(), "banana.txt") + XCTAssertEqual(sortedResults[2].basename(), "zebra.txt") + + // Test unsorted behavior - just verify we get all files, order doesn't matter + let unsortedResults = tmpdir.ls(.unsorted) + XCTAssertEqual(unsortedResults.count, 3) + XCTAssertTrue(unsortedResults.contains(tmpdir.join("apple.txt"))) + XCTAssertTrue(unsortedResults.contains(tmpdir.join("banana.txt"))) + XCTAssertTrue(unsortedResults.contains(tmpdir.join("zebra.txt"))) + } + } + + func testLsUnsortedWithHidden() throws { + try Path.mktemp { tmpdir in + // Create regular and hidden files + try tmpdir.join("visible.txt").touch() + try tmpdir.join(".hidden.txt").touch() + try tmpdir.join("another.txt").touch() + + // Test .a (sorted with hidden) + let sortedWithHidden = tmpdir.ls(.a) + XCTAssertEqual(sortedWithHidden.count, 3) + XCTAssertEqual(sortedWithHidden[0].basename(), ".hidden.txt") + XCTAssertEqual(sortedWithHidden[1].basename(), "another.txt") + XCTAssertEqual(sortedWithHidden[2].basename(), "visible.txt") + + // Test .aUnsorted (unsorted with hidden) + let unsortedWithHidden = tmpdir.ls(.aUnsorted) + XCTAssertEqual(unsortedWithHidden.count, 3) + XCTAssertTrue(unsortedWithHidden.contains(tmpdir.join("visible.txt"))) + XCTAssertTrue(unsortedWithHidden.contains(tmpdir.join(".hidden.txt"))) + XCTAssertTrue(unsortedWithHidden.contains(tmpdir.join("another.txt"))) + + // Test .unsorted (unsorted without hidden) + let unsortedNoHidden = tmpdir.ls(.unsorted) + XCTAssertEqual(unsortedNoHidden.count, 2) + XCTAssertTrue(unsortedNoHidden.contains(tmpdir.join("visible.txt"))) + XCTAssertTrue(unsortedNoHidden.contains(tmpdir.join("another.txt"))) + XCTAssertFalse(unsortedNoHidden.contains(tmpdir.join(".hidden.txt"))) + } + } } diff --git a/Tests/PathTests/PathTests.swift b/Tests/PathTests/PathTests.swift index 65a5ada..5e0f7ab 100644 --- a/Tests/PathTests/PathTests.swift +++ b/Tests/PathTests/PathTests.swift @@ -496,6 +496,13 @@ class PathTests: XCTestCase { } } + func testStringEncodingExtensions() throws { + let string = try String(contentsOf: Path(#file)!, encoding: .utf8) + try Path.mktemp { tmpdir in + _ = try string.write(to: tmpdir.foo, encoding: .utf8) + } + } + func testFileHandleExtensions() throws { _ = try FileHandle(forReadingAt: Path(#file)!) _ = try FileHandle(forWritingAt: Path(#file)!) diff --git a/Tests/PathTests/XCTestManifests.swift b/Tests/PathTests/XCTestManifests.swift index 74d1eb5..cce6468 100644 --- a/Tests/PathTests/XCTestManifests.swift +++ b/Tests/PathTests/XCTestManifests.swift @@ -42,6 +42,8 @@ extension PathTests { ("testKind", testKind), ("testLock", testLock), ("testLsOnNonexistentDirectoryReturnsEmptyArray", testLsOnNonexistentDirectoryReturnsEmptyArray), + ("testLsUnsortedOption", testLsUnsortedOption), + ("testLsUnsortedWithHidden", testLsUnsortedWithHidden), ("testMkpathIfExists", testMkpathIfExists), ("testMktemp", testMktemp), ("testMoveInto", testMoveInto),