diff --git a/Sources/SwiftFindRefs/FileSystem/FileSystem.swift b/Sources/SwiftFindRefs/FileSystem/FileSystem.swift index d769e52..de779a5 100644 --- a/Sources/SwiftFindRefs/FileSystem/FileSystem.swift +++ b/Sources/SwiftFindRefs/FileSystem/FileSystem.swift @@ -59,13 +59,11 @@ final class FileSystem: FileSystemProvider { try String(contentsOfFile: path) } - func readLines(atPath path: String) async throws -> [String] { - let url = URL(fileURLWithPath: path) - var lines: [String] = [] - for try await line in url.resourceBytes.lines { - lines.append(line) - } - return lines + func readLines(atPath path: String) throws -> [String] { + // Read the file content first to preserve empty lines (including trailing ones) + // URL.resourceBytes.lines strips trailing newlines, so we need to split manually + let contents = try readFile(atPath: path) + return contents.components(separatedBy: .newlines) } func writeFile(_ contents: String, toPath path: String) throws { diff --git a/Sources/SwiftFindRefs/FileSystem/FileSystemProvider.swift b/Sources/SwiftFindRefs/FileSystem/FileSystemProvider.swift index 75bd357..6c20883 100644 --- a/Sources/SwiftFindRefs/FileSystem/FileSystemProvider.swift +++ b/Sources/SwiftFindRefs/FileSystem/FileSystemProvider.swift @@ -31,9 +31,9 @@ protocol FileSystemProvider { /// - Parameter path: A file path (absolute or relative). func readFile(atPath path: String) throws -> String - /// Reads the contents of a file as lines asynchronously. + /// Reads the contents of a file as lines. /// - Parameter path: A file path (absolute or relative). - func readLines(atPath path: String) async throws -> [String] + func readLines(atPath path: String) throws -> [String] /// Writes the contents to a file path. /// - Parameters: diff --git a/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/TestableImportExtractor.swift b/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/TestableImportExtractor.swift index 61c1044..589adcc 100644 --- a/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/TestableImportExtractor.swift +++ b/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/TestableImportExtractor.swift @@ -14,7 +14,7 @@ struct TestableImportExtractor: TestableImportExtracting { } func testableImports(inFile path: String) async throws -> Set { - let lines = try await fileSystem.readLines(atPath: path) + let lines = try fileSystem.readLines(atPath: path) var testableImports = Set() var conditionalDepth = 0 diff --git a/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableAnalyzer.swift b/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableAnalyzer.swift index b29b8a9..e7c7233 100644 --- a/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableAnalyzer.swift +++ b/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableAnalyzer.swift @@ -20,7 +20,7 @@ struct UnnecessaryTestableAnalyzer: UnnecessaryTestableAnalyzing { let fileSystemBox = FileSystemBox(fileSystem: fileSystem) let fileLinesCache = FileLinesCache( readLines: { path in - try await fileSystemBox.fileSystem.readLines(atPath: path) + try fileSystemBox.fileSystem.readLines(atPath: path) } ) var mutableTestableImportsByFile: [String: Set] = [:] @@ -264,19 +264,19 @@ private struct RelatedSymbolSnapshot: Sendable { private actor FileLinesCache { private var cache: [String: [String]] = [:] - private let readLines: @Sendable (String) async throws -> [String] + private let readLines: @Sendable (String) throws -> [String] init( - readLines: @escaping @Sendable (String) async throws -> [String] + readLines: @escaping @Sendable (String) throws -> [String] ) { self.readLines = readLines } - func lines(for file: String) async -> [String] { + func lines(for file: String) -> [String] { if let cached = cache[file] { return cached } - let lines = (try? await readLines(file)) ?? [] + let lines = (try? readLines(file)) ?? [] cache[file] = lines return lines } diff --git a/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableRewriter.swift b/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableRewriter.swift index 6014a0c..5a872d5 100644 --- a/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableRewriter.swift +++ b/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableRewriter.swift @@ -17,7 +17,7 @@ struct UnnecessaryTestableRewriter: UnnecessaryTestableRewriting { return try await withThrowingTaskGroup(of: String?.self) { group in for (filePath, modules) in removalsByFile { group.addTask { - let lines = try await fileSystem.fileSystem.readLines(atPath: filePath) + let lines = try fileSystem.fileSystem.readLines(atPath: filePath) if let updated = Self.replaceTestableImports(in: lines, modules: modules) { try fileSystem.fileSystem.writeFile(updated, toPath: filePath) return filePath diff --git a/Tests/SwiftFindRefs/FileSystem/FileSystemTests.swift b/Tests/SwiftFindRefs/FileSystem/FileSystemTests.swift index 1e917b4..e0da666 100644 --- a/Tests/SwiftFindRefs/FileSystem/FileSystemTests.swift +++ b/Tests/SwiftFindRefs/FileSystem/FileSystemTests.swift @@ -136,8 +136,8 @@ struct FileSystemTests { #expect(result == contents) } - @Test("test readLines returns lines asynchronously") - func test_readLines_ReturnsLines() async throws { + @Test("test readLines returns lines") + func test_readLines_ReturnsLines() throws { // Given let fileURL = makeTempFileURL() let contents = "LineA\nLineB\nLineC" @@ -145,12 +145,30 @@ struct FileSystemTests { let sut = makeSUT(fileManager: FileManager.default) // When - let lines = try await sut.readLines(atPath: fileURL.path) + let lines = try sut.readLines(atPath: fileURL.path) // Then #expect(lines == ["LineA", "LineB", "LineC"]) } + @Test("test readLines preserves empty lines including trailing ones") + func test_readLines_PreservesEmptyLines() throws { + // Given + let fileURL = makeTempFileURL() + // File with empty lines in middle and trailing empty lines + let contents = "LineA\n\nLineB\n\n\n" + try contents.write(to: fileURL, atomically: true, encoding: .utf8) + let sut = makeSUT(fileManager: FileManager.default) + + // When + let lines = try sut.readLines(atPath: fileURL.path) + + // Then + // components(separatedBy: .newlines) preserves all empty lines including trailing ones + // "LineA\n\nLineB\n\n\n" should split to ["LineA", "", "LineB", "", "", ""] + #expect(lines == ["LineA", "", "LineB", "", "", ""]) + } + // MARK: - Helpers private func makeSUT(fileManager: FileManager) -> FileSystem { diff --git a/Tests/SwiftFindRefs/Mocks/MockFileSystem.swift b/Tests/SwiftFindRefs/Mocks/MockFileSystem.swift index a694184..0e196b3 100644 --- a/Tests/SwiftFindRefs/Mocks/MockFileSystem.swift +++ b/Tests/SwiftFindRefs/Mocks/MockFileSystem.swift @@ -70,7 +70,7 @@ final class MockFileSystem: FileSystemProvider { return readFileResults[path] ?? "" } - func readLines(atPath path: String) async throws -> [String] { + func readLines(atPath path: String) throws -> [String] { actions.append(.readLines(atPath: path)) if let error = readFileError { throw error diff --git a/Tests/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableRewriterTests.swift b/Tests/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableRewriterTests.swift index cbbb43f..353cb5a 100644 --- a/Tests/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableRewriterTests.swift +++ b/Tests/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableRewriterTests.swift @@ -45,4 +45,164 @@ struct UnnecessaryTestableRewriterTests { #expect(updated.isEmpty) #expect(fileSystem.writtenFiles.isEmpty) } + + @Test("preserves empty lines when rewriting @testable imports") + func test_preservesEmptyLines() async throws { + // Given + let filePath = "/mock/Test.swift" + // File with empty lines at the beginning, middle, end, and multiple consecutive empty lines + let originalContents = """ +import Foundation + +@testable import ModuleA + +import ModuleB + +@testable import ModuleC + +class TestClass { +} + +""" + let fileSystem = MockFileSystem(readFileResults: [filePath: originalContents]) + let sut = UnnecessaryTestableRewriter(fileSystem: fileSystem, print: { _ in }) + + // When + let updated = try await sut.rewriteFiles([filePath: ["ModuleA", "ModuleC"]]) + + // Then + #expect(updated == [filePath]) + let written = try #require(fileSystem.writtenFiles[filePath]) + + // Split both original and written into lines to compare structure + let originalLines = originalContents.components(separatedBy: .newlines) + let writtenLines = written.components(separatedBy: .newlines) + + // The number of lines should match (preserving empty lines) + #expect(writtenLines.count == originalLines.count) + + // Verify that empty lines are preserved at their original positions + for (index, originalLine) in originalLines.enumerated() { + let writtenLine = writtenLines[index] + if originalLine.isEmpty { + // Empty lines must remain empty + #expect(writtenLine.isEmpty, "Empty line at index \(index) was not preserved") + } else if originalLine.trimmingCharacters(in: .whitespaces).hasPrefix("@testable import ModuleA") { + // This line should be rewritten + #expect(writtenLine.trimmingCharacters(in: .whitespaces) == "import ModuleA") + } else if originalLine.trimmingCharacters(in: .whitespaces).hasPrefix("@testable import ModuleC") { + // This line should be rewritten + #expect(writtenLine.trimmingCharacters(in: .whitespaces) == "import ModuleC") + } else { + // All other lines should remain unchanged + #expect(writtenLine == originalLine, "Line at index \(index) was modified: expected '\(originalLine)', got '\(writtenLine)'") + } + } + + // Verify the imports were changed + #expect(written.contains("import ModuleA")) + #expect(written.contains("import ModuleC")) + #expect(!written.contains("@testable import ModuleA")) + #expect(!written.contains("@testable import ModuleC")) + } + + @Test("preserves trailing empty lines and newlines") + func test_preservesTrailingEmptyLines() async throws { + // Given + let filePath = "/mock/Test.swift" + // File ending with multiple empty lines and a newline + let originalContents = """ +@testable import ModuleA +class TestClass { +} + +""" + let fileSystem = MockFileSystem(readFileResults: [filePath: originalContents]) + let sut = UnnecessaryTestableRewriter(fileSystem: fileSystem, print: { _ in }) + + // When + let updated = try await sut.rewriteFiles([filePath: ["ModuleA"]]) + + // Then + #expect(updated == [filePath]) + let written = try #require(fileSystem.writtenFiles[filePath]) + + // Split both original and written into lines to compare structure + let originalLines = originalContents.components(separatedBy: .newlines) + let writtenLines = written.components(separatedBy: .newlines) + + // The number of lines should match exactly (including trailing empty lines) + #expect(writtenLines.count == originalLines.count, + "Line count mismatch: original has \(originalLines.count) lines, written has \(writtenLines.count) lines") + + // Verify trailing empty lines are preserved + // Original: ["@testable import ModuleA", "class TestClass {", "", ""] + // Written should have the same structure + for (index, originalLine) in originalLines.enumerated() { + let writtenLine = writtenLines[index] + if originalLine.isEmpty { + #expect(writtenLine.isEmpty, "Empty line at index \(index) was not preserved") + } + } + + // Verify the last line is empty (trailing newline creates an empty line) + if !originalLines.isEmpty { + let lastOriginalLine = originalLines[originalLines.count - 1] + let lastWrittenLine = writtenLines[writtenLines.count - 1] + #expect(lastWrittenLine == lastOriginalLine, + "Last line mismatch: expected '\(lastOriginalLine)', got '\(lastWrittenLine)'") + } + } + + @Test("preserves multiple consecutive empty lines") + func test_preservesMultipleConsecutiveEmptyLines() async throws { + // Given + let filePath = "/mock/Test.swift" + // File with multiple consecutive empty lines + let originalContents = """ +import Foundation + + +@testable import ModuleA + + +import ModuleB +""" + let fileSystem = MockFileSystem(readFileResults: [filePath: originalContents]) + let sut = UnnecessaryTestableRewriter(fileSystem: fileSystem, print: { _ in }) + + // When + let updated = try await sut.rewriteFiles([filePath: ["ModuleA"]]) + + // Then + #expect(updated == [filePath]) + let written = try #require(fileSystem.writtenFiles[filePath]) + + // Split both original and written into lines + let originalLines = originalContents.components(separatedBy: .newlines) + let writtenLines = written.components(separatedBy: .newlines) + + // Verify exact line count match + #expect(writtenLines.count == originalLines.count, + "Line count mismatch: original has \(originalLines.count) lines, written has \(writtenLines.count) lines") + + // Verify consecutive empty lines are preserved + // Check that empty lines at specific indices are preserved + for (index, originalLine) in originalLines.enumerated() { + let writtenLine = writtenLines[index] + if originalLine.isEmpty { + #expect(writtenLine.isEmpty, "Empty line at index \(index) was not preserved") + } else if originalLine.trimmingCharacters(in: .whitespaces).hasPrefix("@testable import ModuleA") { + #expect(writtenLine.trimmingCharacters(in: .whitespaces) == "import ModuleA") + } else { + #expect(writtenLine == originalLine, "Line at index \(index) was modified: expected '\(originalLine)', got '\(writtenLine)'") + } + } + + // Verify we have consecutive empty lines preserved + let originalEmptyLineIndices = originalLines.enumerated().compactMap { $0.element.isEmpty ? $0.offset : nil } + let writtenEmptyLineIndices = writtenLines.enumerated().compactMap { $0.element.isEmpty ? $0.offset : nil } + #expect(originalEmptyLineIndices == writtenEmptyLineIndices, + "Empty line positions don't match: original at \(originalEmptyLineIndices), written at \(writtenEmptyLineIndices)") + } }