Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 5 additions & 7 deletions Sources/SwiftFindRefs/FileSystem/FileSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions Sources/SwiftFindRefs/FileSystem/FileSystemProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ struct TestableImportExtractor: TestableImportExtracting {
}

func testableImports(inFile path: String) async throws -> Set<String> {
let lines = try await fileSystem.readLines(atPath: path)
let lines = try fileSystem.readLines(atPath: path)
var testableImports = Set<String>()
var conditionalDepth = 0

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>] = [:]
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 21 additions & 3 deletions Tests/SwiftFindRefs/FileSystem/FileSystemTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,21 +136,39 @@ 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"
try contents.write(to: fileURL, atomically: true, encoding: .utf8)
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 {
Expand Down
2 changes: 1 addition & 1 deletion Tests/SwiftFindRefs/Mocks/MockFileSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
}