diff --git a/README.md b/README.md
index 7aeea5d..cea2e85 100644
--- a/README.md
+++ b/README.md
@@ -4,9 +4,15 @@
# π SwiftFindRefs
-A Swift Package Manager CLI that locates every file in your Xcode DerivedData index referencing a chosen symbol. It resolves the correct IndexStore path automatically, queries Appleβs IndexStoreDB, and prints a deduplicated list of source files. It uses Swift concurrency to scan multiple files in parallel, keeping discovery fast even for large workspaces.
+A Swift Package Manager CLI that helps you interact with Xcode's IndexStoreDB. It provides two main features:
+- **Search**: Locates every file in your Xcode DerivedData index referencing a chosen symbol
+- **RemoveUTI**: Automatically removes unnecessary `@testable import` statements from your test files
-## π Common use case
+It resolves the correct IndexStore path automatically, queries Apple's IndexStoreDB, and uses Swift concurrency to scan multiple files in parallel, keeping operations fast even for large workspaces.
+
+## π Common use cases
+
+### Finding symbol references
When working with multiple modules and moving models between them, finding all references to add missing imports is tedious. Using this CLI to feed file lists to AI agents dramatically improves refactoring results.
Just tell your AI agent to execute the script below and add missing import statements to all files
```bash
@@ -16,20 +22,36 @@ swiftfindrefs -p SomeProject -n SomeSymbolName -t SomeSymbolType | while read fi
fi
done
```
+
+### Cleaning up unnecessary testable imports
+After refactoring code to make symbols public, you may have leftover `@testable import` statements in your test files that are no longer needed. This CLI can automatically detect and remove them, keeping your test files clean.
+```bash
+swiftfindrefs rmUTI -p SomeProject
+```
+
## π οΈ Installation
```bash
brew tap michaelversus/SwiftFindRefs https://github.com/michaelversus/SwiftFindRefs.git
brew install swiftfindrefs
```
-## βοΈ Command line flags
+## βοΈ Command line options
+
+### Common options (available for all subcommands)
- `-p, --projectName` helps the tool infer the right DerivedData folder when you do not pass `derivedDataPath`.
- `-d, --derivedDataPath` points directly to a DerivedData (or IndexStoreDB) directory and skips discovery.
+- `-v, --verbose` enables verbose output for diagnostic purposes (flag, no value required).
+
+### Search subcommand
- `-n, --symbolName` is the symbol you want to inspect. This is required.
- `-t, --symbolType` narrows matches to a specific kind (e.g. `class`, `function`).
-- `-v, --verbose` prints discovery steps, resolved paths, and finder diagnostics.
+
+### RemoveUTI subcommand (`removeUnnecessaryTestableImports` or `rmUTI`)
+- `--excludeCompilationConditionals` excludes `@testable import` statements inside `#if/#elseif/#else/#endif` blocks from analysis (useful for multi-target apps).
## π Usage
+
+### Search for symbol references
```bash
swiftfindrefs \
--projectName MyApp \
@@ -45,10 +67,48 @@ Sample output:
...
```
+### Remove unnecessary @testable imports
+```bash
+swiftfindrefs removeUnnecessaryTestableImports \
+ --projectName MyApp \
+ --verbose \
+ --excludeCompilationConditionals
+```
+
+Or use the shorter alias:
+```bash
+swiftfindrefs rmUTI \
+ --projectName MyApp \
+ --verbose \
+ --excludeCompilationConditionals
+```
+
+Sample output:
+```
+DerivedData path: /Users/me/Library/Developer/Xcode/DerivedData/MyApp-...
+IndexStoreDB path: /Users/me/Library/Developer/Xcode/DerivedData/MyApp-.../Index.noindex/DataStore/IndexStoreDB
+Planning to remove unnecessary @testable imports from 3 files.
+Removed unnecessary @testable imports from 3 files.
+β
Updated 3 files
+Updated files:
+/Users/me/MyApp/Tests/SomeTests.swift
+/Users/me/MyApp/Tests/OtherTests.swift
+...
+```
+
## π§ How it works
+
+### Search functionality
1. **Derived data resolution** β `DerivedDataLocator` uses the provided path or infers the newest `ProjectName-*` folder under `~/Library/Developer/Xcode/DerivedData`.
2. **Index routing** β `DerivedDataPaths` ensures the path points into `Index.noindex/DataStore/IndexStoreDB` so we can open the index without extra setup.
-3. **Output formatting** β Paths are normalized, deduplicated, and printed once for easier scripting.
+3. **Symbol querying** β Queries IndexStoreDB for all occurrences of the specified symbol, filtering by type if provided.
+4. **Output formatting** β Paths are normalized, deduplicated, and printed once for easier scripting.
+
+### Remove functionality
+1. **Index analysis** β Scans all units in the IndexStoreDB to identify files with `@testable import` statements.
+2. **Symbol analysis** β For each `@testable import`, checks if any referenced symbols from that module are actually public (and thus don't require `@testable`).
+3. **File rewriting** β Removes unnecessary `@testable` imports from files, preserving other imports and code structure.
+4. **Performance** β Uses async/await and parallel processing to handle large codebases efficiently. Files are read lazily only when needed, and units are indexed by module to avoid O(nΒ²) scans.
## Agent Skill (OpenSkills)
diff --git a/Sources/SwiftFindRefs/CompositionRoot/RemoveCompositionRoot.swift b/Sources/SwiftFindRefs/CompositionRoot/RemoveCompositionRoot.swift
new file mode 100644
index 0000000..23058f8
--- /dev/null
+++ b/Sources/SwiftFindRefs/CompositionRoot/RemoveCompositionRoot.swift
@@ -0,0 +1,28 @@
+import Foundation
+@preconcurrency import IndexStore
+
+struct RemoveCompositionRoot {
+ let projectName: String?
+ let derivedDataPath: String?
+ let excludeCompilationConditionals: Bool
+ let print: (String) -> Void
+ let vPrint: (String) -> Void
+ let fileSystem: FileSystemProvider
+ let derivedDataLocator: DerivedDataLocatorProtocol
+ let removerFactory: (String) -> UnnecessaryTestableRemoving
+
+ func run() async throws {
+ let derivedDataPaths = try derivedDataLocator.locateDerivedData(
+ projectName: projectName,
+ derivedDataPath: derivedDataPath
+ )
+ vPrint("DerivedData path: \(derivedDataPaths.derivedDataURL.path)")
+ vPrint("IndexStoreDB path: \(derivedDataPaths.indexStoreDBURL.path)")
+ let indexStorePath = derivedDataPaths.indexStoreDBURL.deletingLastPathComponent().path
+ let remover = removerFactory(indexStorePath)
+ let updatedFiles = try await remover.run()
+ print("β
Updated \(updatedFiles.count) files")
+ vPrint("Updated files:")
+ updatedFiles.sorted().forEach { vPrint($0) }
+ }
+}
diff --git a/Sources/SwiftFindRefs/CompositionRoot.swift b/Sources/SwiftFindRefs/CompositionRoot/SearchCompositionRoot.swift
similarity index 78%
rename from Sources/SwiftFindRefs/CompositionRoot.swift
rename to Sources/SwiftFindRefs/CompositionRoot/SearchCompositionRoot.swift
index ed7525c..45a3f3f 100644
--- a/Sources/SwiftFindRefs/CompositionRoot.swift
+++ b/Sources/SwiftFindRefs/CompositionRoot/SearchCompositionRoot.swift
@@ -1,6 +1,6 @@
import Foundation
-struct CompositionRoot {
+struct SearchCompositionRoot {
let projectName: String?
let derivedDataPath: String?
let symbolName: String
@@ -17,14 +17,10 @@ struct CompositionRoot {
)
vPrint("DerivedData path: \(derivedDataPaths.derivedDataURL.path)")
vPrint("IndexStoreDB path: \(derivedDataPaths.indexStoreDBURL.path)")
- let indexStoreFinder = IndexStoreFinder(
- indexStorePath: derivedDataPaths.indexStoreDBURL.deletingLastPathComponent().path
- )
+ let indexStorePath = derivedDataPaths.indexStoreDBURL.deletingLastPathComponent().path
+ let indexStoreFinder = IndexStoreFinder(indexStorePath: indexStorePath)
print("π Searching for references to symbol '\(symbolName)' of type '\(symbolType ?? "any")'")
- let references = try await indexStoreFinder.fileReferences(
- of: symbolName,
- symbolType: symbolType
- )
+ let references = try await indexStoreFinder.fileReferences(of: symbolName, symbolType: symbolType)
print("β
Found \(references.count) references:\n\(references.joined(separator: "\n"))")
}
}
diff --git a/Sources/SwiftFindRefs/FileSystem/FileSystem.swift b/Sources/SwiftFindRefs/FileSystem/FileSystem.swift
index 41dab46..d769e52 100644
--- a/Sources/SwiftFindRefs/FileSystem/FileSystem.swift
+++ b/Sources/SwiftFindRefs/FileSystem/FileSystem.swift
@@ -54,4 +54,21 @@ final class FileSystem: FileSystemProvider {
options: mask
)
}
+
+ func readFile(atPath path: String) throws -> String {
+ 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 writeFile(_ contents: String, toPath path: String) throws {
+ try contents.write(toFile: path, atomically: true, encoding: .utf8)
+ }
}
diff --git a/Sources/SwiftFindRefs/FileSystem/FileSystemProvider.swift b/Sources/SwiftFindRefs/FileSystem/FileSystemProvider.swift
index 608ba78..75bd357 100644
--- a/Sources/SwiftFindRefs/FileSystem/FileSystemProvider.swift
+++ b/Sources/SwiftFindRefs/FileSystem/FileSystemProvider.swift
@@ -26,4 +26,18 @@ protocol FileSystemProvider {
includingPropertiesForKeys keys: [URLResourceKey]?,
options mask: FileManager.DirectoryEnumerationOptions
) throws -> [URL]
+
+ /// Reads the contents of a file as a string.
+ /// - Parameter path: A file path (absolute or relative).
+ func readFile(atPath path: String) throws -> String
+
+ /// Reads the contents of a file as lines asynchronously.
+ /// - Parameter path: A file path (absolute or relative).
+ func readLines(atPath path: String) async throws -> [String]
+
+ /// Writes the contents to a file path.
+ /// - Parameters:
+ /// - contents: The string to write.
+ /// - path: A file path (absolute or relative).
+ func writeFile(_ contents: String, toPath path: String) throws
}
diff --git a/Sources/SwiftFindRefs/IndexStore/IndexStore+Providing.swift b/Sources/SwiftFindRefs/IndexStore/IndexStore+Providing.swift
index 94205d5..fce2ccc 100644
--- a/Sources/SwiftFindRefs/IndexStore/IndexStore+Providing.swift
+++ b/Sources/SwiftFindRefs/IndexStore/IndexStore+Providing.swift
@@ -36,4 +36,20 @@ extension SymbolOccurrence: SymbolOccurrenceProviding {
var symbolMatching: SymbolMatching {
symbol
}
+
+ var locationLine: Int {
+ location.line
+ }
+
+ var symbolUSR: String {
+ symbol.usr
+ }
+
+ func forEachRelatedSymbol(_ callback: (RelatedSymbolProviding, SymbolRoles) -> Void) {
+ forEach { symbol, roles in
+ callback(symbol, roles)
+ }
+ }
}
+
+extension Symbol: RelatedSymbolProviding {}
diff --git a/Sources/SwiftFindRefs/IndexStore/IndexStoreProviding.swift b/Sources/SwiftFindRefs/IndexStore/IndexStoreProviding.swift
index ec184c1..017e116 100644
--- a/Sources/SwiftFindRefs/IndexStore/IndexStoreProviding.swift
+++ b/Sources/SwiftFindRefs/IndexStore/IndexStoreProviding.swift
@@ -10,6 +10,9 @@ protocol UnitDependencyProviding {
/// Protocol for unit reader abstraction, enabling testability
protocol UnitReaderProviding {
var isSystem: Bool { get }
+ var mainFile: String { get }
+ var moduleName: String { get }
+ var recordName: String? { get }
func forEachDependency(_ callback: (UnitDependencyProviding) -> Void)
}
@@ -27,4 +30,13 @@ protocol RecordReaderProviding {
/// Protocol for symbol occurrence abstraction, enabling testability
protocol SymbolOccurrenceProviding {
var symbolMatching: SymbolMatching { get }
+ var roles: SymbolRoles { get }
+ var locationLine: Int { get }
+ var symbolUSR: String { get }
+ func forEachRelatedSymbol(_ callback: (RelatedSymbolProviding, SymbolRoles) -> Void)
+}
+
+/// Protocol for related symbols attached to a symbol occurrence.
+protocol RelatedSymbolProviding {
+ var kind: SymbolKind { get }
}
diff --git a/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/TestableImportExtracting.swift b/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/TestableImportExtracting.swift
new file mode 100644
index 0000000..2da98ab
--- /dev/null
+++ b/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/TestableImportExtracting.swift
@@ -0,0 +1,3 @@
+protocol TestableImportExtracting {
+ func testableImports(inFile path: String) async throws -> Set
+}
diff --git a/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/TestableImportExtractor.swift b/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/TestableImportExtractor.swift
new file mode 100644
index 0000000..61c1044
--- /dev/null
+++ b/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/TestableImportExtractor.swift
@@ -0,0 +1,49 @@
+import Foundation
+
+struct TestableImportExtractor: TestableImportExtracting {
+ private let fileSystem: FileSystemProvider
+ private let excludeCompilationConditionals: Bool
+ private let testablePrefix = "@testable import "
+
+ init(
+ fileSystem: FileSystemProvider,
+ excludeCompilationConditionals: Bool
+ ) {
+ self.fileSystem = fileSystem
+ self.excludeCompilationConditionals = excludeCompilationConditionals
+ }
+
+ func testableImports(inFile path: String) async throws -> Set {
+ let lines = try await fileSystem.readLines(atPath: path)
+ var testableImports = Set()
+ var conditionalDepth = 0
+
+ for line in lines {
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ if trimmed.hasPrefix("#if") {
+ conditionalDepth += 1
+ continue
+ }
+ if trimmed.hasPrefix("#elseif") || trimmed.hasPrefix("#else") {
+ continue
+ }
+ if trimmed.hasPrefix("#endif") {
+ conditionalDepth = max(0, conditionalDepth - 1)
+ continue
+ }
+
+ if trimmed.hasPrefix(testablePrefix) {
+ if excludeCompilationConditionals && conditionalDepth > 0 {
+ continue
+ }
+ let modulePart = trimmed.dropFirst(testablePrefix.count)
+ let moduleName = modulePart.split(whereSeparator: { $0 == " " || $0 == "\t" || $0 == "." }).first
+ if let moduleName {
+ testableImports.insert(String(moduleName))
+ }
+ }
+ }
+
+ return testableImports
+ }
+}
diff --git a/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableAnalyzer.swift b/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableAnalyzer.swift
new file mode 100644
index 0000000..b29b8a9
--- /dev/null
+++ b/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableAnalyzer.swift
@@ -0,0 +1,288 @@
+import Foundation
+@preconcurrency import IndexStore
+
+struct UnnecessaryTestableAnalyzer: UnnecessaryTestableAnalyzing {
+ private let fileSystem: FileSystemProvider
+ private let extractor: TestableImportExtracting
+
+ init(
+ fileSystem: FileSystemProvider,
+ extractor: TestableImportExtracting,
+ ) {
+ self.fileSystem = fileSystem
+ self.extractor = extractor
+ }
+
+ func analyze(store: some IndexStoreProviding, indexStorePath: String) async throws -> [String: Set] {
+ let (units, occurrencesByFile) = try collectUnitsAndRecords(store: store, indexStorePath: indexStorePath)
+ let unitSnapshots = units.map { UnitSnapshot(mainFile: $0.mainFile, moduleName: $0.moduleName) }
+ let unitsByModule = Dictionary(grouping: unitSnapshots, by: \.moduleName)
+ let fileSystemBox = FileSystemBox(fileSystem: fileSystem)
+ let fileLinesCache = FileLinesCache(
+ readLines: { path in
+ try await fileSystemBox.fileSystem.readLines(atPath: path)
+ }
+ )
+ var mutableTestableImportsByFile: [String: Set] = [:]
+ for unit in unitSnapshots where !Self.isGeneratedFile(unit.mainFile) {
+ let testableImports = try await extractor.testableImports(inFile: unit.mainFile)
+ if !testableImports.isEmpty {
+ mutableTestableImportsByFile[unit.mainFile] = testableImports
+ }
+ }
+ let testableImportsByFile = mutableTestableImportsByFile
+
+ return try await withThrowingTaskGroup(of: (String, Set)?.self) { group in
+ for unit in unitSnapshots {
+ group.addTask {
+ if Self.isGeneratedFile(unit.mainFile) {
+ return nil
+ }
+
+ guard let testableImports = testableImportsByFile[unit.mainFile],
+ !testableImports.isEmpty else {
+ return nil
+ }
+
+ let (referencedUSRs, overrideUSRs) = Self.getReferenceUSRs(
+ mainFile: unit.mainFile,
+ occurrencesByFile: occurrencesByFile
+ )
+ var seenModules = Set()
+ var requiredTestableImports = Set()
+
+ for moduleName in testableImports {
+ if requiredTestableImports.contains(moduleName) {
+ continue
+ }
+ guard let dependentUnits = unitsByModule[moduleName] else {
+ continue
+ }
+
+ var hadOccurrences = false
+ for dependentUnit in dependentUnits {
+ guard let occurrences = occurrencesByFile[dependentUnit.mainFile] else {
+ continue
+ }
+ hadOccurrences = true
+
+ for occurrence in occurrences {
+ if
+ occurrence.roles.contains(.definition),
+ referencedUSRs.contains(occurrence.symbolUSR),
+ !Self.isChildOfProtocol(occurrence: occurrence),
+ !Self.isGetterOrSetterFunction(occurrence: occurrence),
+ !(await Self.isPublic(
+ file: dependentUnit.mainFile,
+ occurrence: occurrence,
+ isOverride: overrideUSRs.contains(occurrence.symbolUSR),
+ fileLinesCache: fileLinesCache
+ ))
+ {
+ requiredTestableImports.insert(moduleName)
+ break
+ }
+ }
+ if requiredTestableImports.contains(moduleName) {
+ break
+ }
+ }
+ if hadOccurrences {
+ seenModules.insert(moduleName)
+ }
+ }
+
+ let missingTestableModules = testableImports.subtracting(seenModules)
+ if !missingTestableModules.isEmpty {
+ throw UnnecessaryTestableError.missingModuleInIndex(
+ file: unit.mainFile,
+ modules: missingTestableModules
+ )
+ }
+
+ let unnecessary = testableImports
+ .intersection(seenModules)
+ .subtracting(requiredTestableImports)
+ if !unnecessary.isEmpty {
+ return (unit.mainFile, unnecessary)
+ }
+
+ return nil
+ }
+ }
+
+ var results: [String: Set] = [:]
+ for try await result in group {
+ if let (file, unnecessary) = result {
+ results[file] = unnecessary
+ }
+ }
+ return results
+ }
+ }
+
+ private static func getReferenceUSRs(
+ mainFile: String,
+ occurrencesByFile: [String: [OccurrenceSnapshot]]
+ ) -> (Set, Set) {
+ guard let occurrences = occurrencesByFile[mainFile] else {
+ return ([], [])
+ }
+
+ var usrs = Set()
+ var overrideUSRs = Set()
+ for occurrence in occurrences {
+ if occurrence.roles.contains(.reference) {
+ usrs.insert(occurrence.symbolUSR)
+ if occurrence.roles.contains(.overrideOf) || occurrence.roles.contains(.baseOf) {
+ overrideUSRs.insert(occurrence.symbolUSR)
+ }
+ }
+ }
+
+ return (usrs, overrideUSRs)
+ }
+
+ private func collectUnitsAndRecords(
+ store: some IndexStoreProviding,
+ indexStorePath: String
+ ) throws -> ([UnitReaderProviding], [String: [OccurrenceSnapshot]]) {
+ var units: [UnitReaderProviding] = []
+ var occurrencesByFile: [String: [OccurrenceSnapshot]] = [:]
+ store.forEachUnit { unitReader in
+ if unitReader.mainFile.isEmpty {
+ return
+ }
+
+ units.append(unitReader)
+ if let recordName = unitReader.recordName,
+ let recordReader = try? store.recordReader(for: recordName) {
+ // IndexStore can return multiple units for the same file (e.g. multiple targets);
+ // keep the first record to avoid failing when duplicates exist.
+ if occurrencesByFile[unitReader.mainFile] == nil {
+ var occurrences: [OccurrenceSnapshot] = []
+ recordReader.forEachOccurrence { occurrence in
+ var relatedSymbols: [RelatedSymbolSnapshot] = []
+ occurrence.forEachRelatedSymbol { symbol, roles in
+ relatedSymbols.append(
+ RelatedSymbolSnapshot(kind: symbol.kind, roles: roles)
+ )
+ }
+ occurrences.append(
+ OccurrenceSnapshot(
+ symbolKind: occurrence.symbolMatching.kind,
+ roles: occurrence.roles,
+ locationLine: occurrence.locationLine,
+ symbolUSR: occurrence.symbolUSR,
+ relatedSymbols: relatedSymbols
+ )
+ )
+ }
+ occurrencesByFile[unitReader.mainFile] = occurrences
+ }
+ }
+ }
+
+ guard !units.isEmpty else {
+ throw UnnecessaryTestableError.failedToLoadUnits(indexStorePath)
+ }
+
+ return (units, occurrencesByFile)
+ }
+
+ private static func isGeneratedFile(_ path: String) -> Bool {
+ path.hasSuffix(".generated.swift")
+ }
+
+ private static func isPublic(
+ file: String,
+ occurrence: OccurrenceSnapshot,
+ isOverride: Bool,
+ fileLinesCache: FileLinesCache
+ ) async -> Bool {
+ if occurrence.roles.contains(.implicit) && !occurrence.roles.contains(.accessorOf) {
+ return false
+ }
+
+ if occurrence.symbolKind == .enumConstant {
+ return true
+ }
+
+ let lines = await fileLinesCache.lines(for: file)
+ let lineIndex = occurrence.locationLine - 1
+ guard lineIndex >= 0, lineIndex < lines.count else {
+ return false
+ }
+ let text = lines[lineIndex]
+ let isPublic = (text.contains("public ") && !isOverride) || text.contains("open ")
+ return isPublic && !text.contains(" internal(")
+ }
+
+ private static func isChildOfProtocol(occurrence: OccurrenceSnapshot) -> Bool {
+ let protocolChildrenTypes: [SymbolKind] = [
+ .instanceMethod, .classMethod, .staticMethod,
+ .instanceProperty, .classProperty, .staticProperty,
+ ]
+ guard protocolChildrenTypes.contains(occurrence.symbolKind) else {
+ return false
+ }
+
+ for related in occurrence.relatedSymbols {
+ if related.roles.contains(.childOf) && related.kind == .protocol {
+ return true
+ }
+ }
+ return false
+ }
+
+ private static func isGetterOrSetterFunction(occurrence: OccurrenceSnapshot) -> Bool {
+ let functionTypes: [SymbolKind] = [.classMethod, .instanceMethod, .staticMethod]
+ guard functionTypes.contains(occurrence.symbolKind) else {
+ return false
+ }
+ return occurrence.roles.contains(.accessorOf)
+ }
+}
+
+private struct OccurrenceSnapshot: Sendable {
+ let symbolKind: SymbolKind
+ let roles: SymbolRoles
+ let locationLine: Int
+ let symbolUSR: String
+ let relatedSymbols: [RelatedSymbolSnapshot]
+}
+
+private struct UnitSnapshot: Sendable {
+ let mainFile: String
+ let moduleName: String
+}
+
+private struct RelatedSymbolSnapshot: Sendable {
+ let kind: SymbolKind
+ let roles: SymbolRoles
+}
+
+private actor FileLinesCache {
+ private var cache: [String: [String]] = [:]
+ private let readLines: @Sendable (String) async throws -> [String]
+
+ init(
+ readLines: @escaping @Sendable (String) async throws -> [String]
+ ) {
+ self.readLines = readLines
+ }
+
+ func lines(for file: String) async -> [String] {
+ if let cached = cache[file] {
+ return cached
+ }
+ let lines = (try? await readLines(file)) ?? []
+ cache[file] = lines
+ return lines
+ }
+}
+
+private struct FileSystemBox: @unchecked Sendable {
+ // FileManager is thread-safe for concurrent reads across different files.
+ let fileSystem: FileSystemProvider
+}
diff --git a/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableAnalyzing.swift b/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableAnalyzing.swift
new file mode 100644
index 0000000..9adbabf
--- /dev/null
+++ b/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableAnalyzing.swift
@@ -0,0 +1,5 @@
+import Foundation
+
+protocol UnnecessaryTestableAnalyzing {
+ func analyze(store: some IndexStoreProviding, indexStorePath: String) async throws -> [String: Set]
+}
diff --git a/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableError.swift b/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableError.swift
new file mode 100644
index 0000000..5a3b330
--- /dev/null
+++ b/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableError.swift
@@ -0,0 +1,24 @@
+import Foundation
+
+enum UnnecessaryTestableError: Error, LocalizedError {
+ case failedToOpenIndexStore(String)
+ case failedToLoadUnits(String)
+ case duplicateRecord(String)
+ case missingModuleInIndex(file: String, modules: Set)
+ case missingSourceLine(file: String, line: Int)
+
+ var errorDescription: String? {
+ switch self {
+ case .failedToOpenIndexStore(let path):
+ return "Failed to open index store at \(path)."
+ case .failedToLoadUnits(let path):
+ return "Failed to load units from index store at \(path)."
+ case .duplicateRecord(let file):
+ return "Found duplicate record for \(file)."
+ case .missingModuleInIndex(let file, let modules):
+ return "Some modules imported with @testable were not included in the index \(file): \(modules)"
+ case .missingSourceLine(let file, let line):
+ return "Could not read line \(line) in \(file)."
+ }
+ }
+}
diff --git a/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableRemover.swift b/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableRemover.swift
new file mode 100644
index 0000000..70c65c3
--- /dev/null
+++ b/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableRemover.swift
@@ -0,0 +1,48 @@
+import Foundation
+@preconcurrency import IndexStore
+
+struct UnnecessaryTestableRemover {
+ let indexStorePath: String
+ let print: (String) -> Void
+ private let storeFactory: () throws -> IndexStoreProviding
+ private let analyzer: UnnecessaryTestableAnalyzing
+ private let rewriter: UnnecessaryTestableRewriting
+
+ init(
+ indexStorePath: String,
+ print: @escaping (String) -> Void,
+ storeFactory: @escaping () throws -> IndexStoreProviding,
+ analyzer: UnnecessaryTestableAnalyzing,
+ rewriter: UnnecessaryTestableRewriting
+ ) {
+ self.indexStorePath = indexStorePath
+ self.print = print
+ self.storeFactory = storeFactory
+ self.analyzer = analyzer
+ self.rewriter = rewriter
+ }
+
+ func run() async throws -> [String] {
+ let store: IndexStoreProviding
+ do {
+ store = try storeFactory()
+ } catch {
+ throw UnnecessaryTestableError.failedToOpenIndexStore(indexStorePath)
+ }
+
+ let removalsByFile = try await analyzer.analyze(store: store, indexStorePath: indexStorePath)
+ guard !removalsByFile.isEmpty else {
+ return []
+ }
+ print("Planning to remove unnecessary @testable imports from \(removalsByFile.count) files.")
+ let updatedFiles = try await rewriter.rewriteFiles(removalsByFile)
+ print("Removed unnecessary @testable imports from \(updatedFiles.count) files.")
+ return updatedFiles
+ }
+}
+
+protocol UnnecessaryTestableRemoving {
+ func run() async throws -> [String]
+}
+
+extension UnnecessaryTestableRemover: UnnecessaryTestableRemoving {}
diff --git a/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableRewriter.swift b/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableRewriter.swift
new file mode 100644
index 0000000..6014a0c
--- /dev/null
+++ b/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableRewriter.swift
@@ -0,0 +1,73 @@
+import Foundation
+
+struct UnnecessaryTestableRewriter: UnnecessaryTestableRewriting {
+ private let fileSystem: FileSystemProvider
+ private let print: (String) -> Void
+
+ init(fileSystem: FileSystemProvider, print: @escaping (String) -> Void) {
+ self.fileSystem = fileSystem
+ self.print = print
+ }
+
+ func rewriteFiles(_ removalsByFile: [String: Set]) async throws -> [String] {
+ let fileSystem = FileSystemBox(fileSystem: self.fileSystem)
+ let print = PrintBox(print: self.print)
+ print.print("Rewriting files: \(removalsByFile.count)")
+
+ 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)
+ if let updated = Self.replaceTestableImports(in: lines, modules: modules) {
+ try fileSystem.fileSystem.writeFile(updated, toPath: filePath)
+ return filePath
+ }
+ return nil
+ }
+ }
+
+ var updatedFiles: [String] = []
+ for try await result in group {
+ if let filePath = result {
+ updatedFiles.append(filePath)
+ }
+ }
+ return updatedFiles
+ }
+ }
+
+ private static func replaceTestableImports(in lines: [String], modules: Set) -> String? {
+ let prefix = "@testable import "
+ var updatedLines: [String]? = nil
+
+ for (index, line) in lines.enumerated() {
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ guard trimmed.hasPrefix(prefix) else {
+ continue
+ }
+ let moduleName = String(trimmed.dropFirst(prefix.count))
+ guard modules.contains(moduleName) else {
+ continue
+ }
+ if updatedLines == nil {
+ updatedLines = lines
+ }
+ let leadingWhitespace = line.prefix { $0 == " " || $0 == "\t" }
+ updatedLines?[index] = "\(leadingWhitespace)import \(moduleName)"
+ }
+
+ return updatedLines?.joined(separator: "\n")
+ }
+}
+
+// MARK: - Sendable wrappers
+
+private struct FileSystemBox: @unchecked Sendable {
+ // FileManager is thread-safe for concurrent reads/writes to different files.
+ let fileSystem: FileSystemProvider
+}
+
+private struct PrintBox: @unchecked Sendable {
+ // Printing is treated as fire-and-forget logging for parallel tasks.
+ let print: (String) -> Void
+}
diff --git a/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableRewriting.swift b/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableRewriting.swift
new file mode 100644
index 0000000..3494bdd
--- /dev/null
+++ b/Sources/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableRewriting.swift
@@ -0,0 +1,5 @@
+import Foundation
+
+protocol UnnecessaryTestableRewriting {
+ func rewriteFiles(_ removalsByFile: [String: Set]) async throws -> [String]
+}
diff --git a/Sources/SwiftFindRefs/SwiftFindRefs.swift b/Sources/SwiftFindRefs/SwiftFindRefs.swift
index e05043a..5c988d5 100644
--- a/Sources/SwiftFindRefs/SwiftFindRefs.swift
+++ b/Sources/SwiftFindRefs/SwiftFindRefs.swift
@@ -1,39 +1,106 @@
import ArgumentParser
import Foundation
+@preconcurrency import IndexStore
@main
struct SwiftFindRefs: AsyncParsableCommand {
- @Option(name: [.short, .customLong("projectName")], help: "The name of the Xcode project to help CLI find the Derived Data Index Store Path")
- var projectName: String?
+ static let configuration = CommandConfiguration(
+ abstract: "CLI that helps you interact with Xcode's IndexStoreDB.",
+ subcommands: [Search.self, Remove.self],
+ defaultSubcommand: Search.self
+ )
+}
- @Option(name: [.short, .customLong("derivedDataPath")], help: "The Derived Data path where Xcode stores build data")
- var derivedDataPath: String?
+extension SwiftFindRefs {
+ struct CommonOptions: ParsableArguments {
+ @Option(name: [.short, .customLong("projectName")], help: "The name of the Xcode project to help CLI find the Derived Data Index Store Path")
+ var projectName: String?
- @Option(name: [.short, .customLong("symbolName")], help: "The symbol name to find references for")
- var name: String
+ @Option(name: [.short, .customLong("derivedDataPath")], help: "The Derived Data path where Xcode stores build data")
+ var derivedDataPath: String?
- @Option(name: [.short, .customLong("symbolType")], help: "The symbol type (e.g., function, variable, class)")
- var type: String?
+ /// Flag to enable verbose output for diagnostic purposes.
+ @Flag(name: .shortAndLong, help: "Enable verbose output.")
+ var verbose: Bool = false
- /// Flag to enable verbose output for diagnostic purposes.
- @Option(name: .shortAndLong, help: "Flag to enable verbose output.")
- var verbose: Bool = false
+ func validate() throws {
+ guard projectName?.isEmpty == false || derivedDataPath?.isEmpty == false else {
+ throw ValidationError("Provide either --projectName or --derivedDataPath.")
+ }
+ }
+ }
- func run() async throws {
- let fileSystem = FileSystem(
- fileManager: FileManager.default
- )
- let derivedDataLocator = DerivedDataLocator(fileSystem: fileSystem)
- let compositionRoot = CompositionRoot(
- projectName: projectName,
- derivedDataPath: derivedDataPath,
- symbolName: name,
- symbolType: type,
- print: { print($0) },
- vPrint: { if verbose { print($0) } },
- fileSystem: fileSystem,
- derivedDataLocator: derivedDataLocator
+ struct Search: AsyncParsableCommand {
+ static let configuration = CommandConfiguration(abstract: "Search for symbol references.")
+
+ @OptionGroup
+ var common: CommonOptions
+
+ @Option(name: [.short, .customLong("symbolName")], help: "The symbol name to find references for")
+ var name: String
+
+ @Option(name: [.short, .customLong("symbolType")], help: "The symbol type (e.g., function, variable, class)")
+ var type: String?
+
+ func run() async throws {
+ let fileSystem = FileSystem(fileManager: FileManager.default)
+ let derivedDataLocator = DerivedDataLocator(fileSystem: fileSystem)
+ let compositionRoot = SearchCompositionRoot(
+ projectName: common.projectName,
+ derivedDataPath: common.derivedDataPath,
+ symbolName: name,
+ symbolType: type,
+ print: { print($0) },
+ vPrint: { if common.verbose { print($0) } },
+ fileSystem: fileSystem,
+ derivedDataLocator: derivedDataLocator
+ )
+ try await compositionRoot.run()
+ }
+ }
+
+ struct Remove: AsyncParsableCommand {
+ static let configuration = CommandConfiguration(
+ commandName: "removeUnnecessaryTestableImports",
+ abstract: "Remove unnecessary @testable imports.",
+ aliases: ["rmUTI"]
)
- try await compositionRoot.run()
+
+ @OptionGroup
+ var common: CommonOptions
+
+ @Flag(name: .customLong("excludeCompilationConditionals"),
+ help: "Exclude @testable imports inside #if/#elseif/#else/#endif blocks.")
+ var excludeCompilationConditionals: Bool = false
+
+ func run() async throws {
+ let fileSystem = FileSystem(fileManager: FileManager.default)
+ let derivedDataLocator = DerivedDataLocator(fileSystem: fileSystem)
+ let compositionRoot = RemoveCompositionRoot(
+ projectName: common.projectName,
+ derivedDataPath: common.derivedDataPath,
+ excludeCompilationConditionals: excludeCompilationConditionals,
+ print: { print($0) },
+ vPrint: { if common.verbose { print($0) } },
+ fileSystem: fileSystem,
+ derivedDataLocator: derivedDataLocator,
+ removerFactory: { indexStorePath in
+ UnnecessaryTestableRemover(
+ indexStorePath: indexStorePath,
+ print: { print($0) },
+ storeFactory: { try IndexStore(path: indexStorePath) },
+ analyzer: UnnecessaryTestableAnalyzer(
+ fileSystem: fileSystem,
+ extractor: TestableImportExtractor(
+ fileSystem: fileSystem,
+ excludeCompilationConditionals: excludeCompilationConditionals
+ )
+ ),
+ rewriter: UnnecessaryTestableRewriter(fileSystem: fileSystem, print: { print($0) })
+ )
+ }
+ )
+ try await compositionRoot.run()
+ }
}
}
diff --git a/Tests/SwiftFindRefs/CompositionRoot/RemoveCompositionRootTests.swift b/Tests/SwiftFindRefs/CompositionRoot/RemoveCompositionRootTests.swift
new file mode 100644
index 0000000..c8dff1d
--- /dev/null
+++ b/Tests/SwiftFindRefs/CompositionRoot/RemoveCompositionRootTests.swift
@@ -0,0 +1,115 @@
+import Testing
+@testable import SwiftFindRefs
+
+@Suite("RemoveCompositionRoot Tests")
+struct RemoveCompositionRootTests {
+ @Test("test run with invalid derived data path throws invalid path error")
+ func test_run_WithInvalidDerivedDataPath_throwsInvalidPathError() async {
+ // Given
+ let invalidPath = "/invalid/DerivedData"
+ let fileSystem = MockFileSystem(fileExistsResults: [invalidPath: false])
+ let sut = makeRemoveSUT(
+ projectName: "Project",
+ derivedDataPath: invalidPath,
+ excludeCompilationConditionals: false,
+ fileSystem: fileSystem,
+ removerFactory: { _ in MockRemover(result: []) }
+ )
+
+ // When
+ let error = await #expect(throws: DerivedDataLocatorError.self) {
+ try await sut.run()
+ }
+
+ // Then
+ guard case .invalidPath(let path) = error else {
+ Issue.record("Expected .invalidPath but received \(error)")
+ return
+ }
+ #expect(path == invalidPath)
+ }
+
+ @Test("test run prints updated files count when nothing updated")
+ func test_run_WhenNoUpdates_PrintsUpdatedCount() async throws {
+ // Given
+ let derivedDataPath = "/mock/DerivedData/IndexStoreDB"
+ let fileSystem = MockFileSystem(fileExistsResults: [derivedDataPath: true])
+ var printMessages: [String] = []
+ let sut = makeRemoveSUT(
+ projectName: "Project",
+ derivedDataPath: derivedDataPath,
+ excludeCompilationConditionals: false,
+ fileSystem: fileSystem,
+ print: { printMessages.append($0) },
+ removerFactory: { _ in MockRemover(result: []) }
+ )
+
+ // When
+ try await sut.run()
+
+ // Then
+ #expect(printMessages.contains("β
Updated 0 files"))
+ }
+
+ @Test("test run prints updated files when remover returns results")
+ func test_run_WhenUpdatesExist_PrintsUpdatedFiles() async throws {
+ // Given
+ let derivedDataPath = "/mock/DerivedData/IndexStoreDB"
+ let fileSystem = MockFileSystem(fileExistsResults: [derivedDataPath: true])
+ var printMessages: [String] = []
+ var vPrintMessages: [String] = []
+ let updatedFiles = ["/mock/FileA.swift", "/mock/FileB.swift"]
+ var receivedIndexStorePath: String?
+ let sut = makeRemoveSUT(
+ projectName: "Project",
+ derivedDataPath: derivedDataPath,
+ excludeCompilationConditionals: false,
+ fileSystem: fileSystem,
+ print: { printMessages.append($0) },
+ vPrint: { vPrintMessages.append($0) },
+ removerFactory: { path in
+ receivedIndexStorePath = path
+ return MockRemover(result: updatedFiles)
+ }
+ )
+
+ // When
+ try await sut.run()
+
+ // Then
+ #expect(receivedIndexStorePath == "/mock/DerivedData")
+ #expect(printMessages.contains("β
Updated 2 files"))
+ #expect(vPrintMessages.contains("Updated files:"))
+ #expect(vPrintMessages.contains("/mock/FileA.swift"))
+ #expect(vPrintMessages.contains("/mock/FileB.swift"))
+ }
+
+ private func makeRemoveSUT(
+ projectName: String?,
+ derivedDataPath: String?,
+ excludeCompilationConditionals: Bool,
+ fileSystem: MockFileSystem,
+ print: @escaping (String) -> Void = { _ in },
+ vPrint: @escaping (String) -> Void = { _ in },
+ removerFactory: @escaping (String) -> UnnecessaryTestableRemoving
+ ) -> RemoveCompositionRoot {
+ RemoveCompositionRoot(
+ projectName: projectName,
+ derivedDataPath: derivedDataPath,
+ excludeCompilationConditionals: excludeCompilationConditionals,
+ print: print,
+ vPrint: vPrint,
+ fileSystem: fileSystem,
+ derivedDataLocator: DerivedDataLocator(fileSystem: fileSystem),
+ removerFactory: removerFactory
+ )
+ }
+}
+
+private struct MockRemover: UnnecessaryTestableRemoving {
+ let result: [String]
+
+ func run() async throws -> [String] {
+ result
+ }
+}
diff --git a/Tests/SwiftFindRefs/CompositionRootTests.swift b/Tests/SwiftFindRefs/CompositionRoot/SearchCompositionRootTests.swift
similarity index 92%
rename from Tests/SwiftFindRefs/CompositionRootTests.swift
rename to Tests/SwiftFindRefs/CompositionRoot/SearchCompositionRootTests.swift
index eea1110..04d3858 100644
--- a/Tests/SwiftFindRefs/CompositionRootTests.swift
+++ b/Tests/SwiftFindRefs/CompositionRoot/SearchCompositionRootTests.swift
@@ -1,8 +1,8 @@
import Testing
@testable import SwiftFindRefs
-@Suite("CompositionRoot Tests")
-struct CompositionRootTests {
+@Suite("SearchCompositionRoot Tests")
+struct SearchCompositionRootTests {
// MARK: - Tests
@@ -10,7 +10,7 @@ struct CompositionRootTests {
func test_run_WithMissingInputs_throwsMissingInputsError() async {
// Given
let fileSystem = MockFileSystem()
- let sut = makeSUT(
+ let sut = makeSearchSUT(
projectName: nil,
derivedDataPath: nil,
symbolType: "class",
@@ -34,7 +34,7 @@ struct CompositionRootTests {
// Given
let invalidPath = "/invalid/DerivedData"
let fileSystem = MockFileSystem(fileExistsResults: [invalidPath: false])
- let sut = makeSUT(
+ let sut = makeSearchSUT(
projectName: "Project",
derivedDataPath: invalidPath,
symbolType: "class",
@@ -61,7 +61,7 @@ struct CompositionRootTests {
let fileSystem = MockFileSystem(fileExistsResults: [derivedDataPath: true])
var standardMessages: [String] = []
var verboseMessages: [String] = []
- let sut = makeSUT(
+ let sut = makeSearchSUT(
projectName: "Project",
derivedDataPath: derivedDataPath,
symbolType: nil,
@@ -85,7 +85,7 @@ struct CompositionRootTests {
// MARK: - Helpers
- private func makeSUT(
+ private func makeSearchSUT(
projectName: String?,
derivedDataPath: String?,
symbolName: String = "MySymbol",
@@ -93,8 +93,8 @@ struct CompositionRootTests {
fileSystem: MockFileSystem,
print: @escaping (String) -> Void = { _ in },
vPrint: @escaping (String) -> Void = { _ in }
- ) -> CompositionRoot {
- CompositionRoot(
+ ) -> SearchCompositionRoot {
+ SearchCompositionRoot(
projectName: projectName,
derivedDataPath: derivedDataPath,
symbolName: symbolName,
diff --git a/Tests/SwiftFindRefs/FileSystem/FileSystemTests.swift b/Tests/SwiftFindRefs/FileSystem/FileSystemTests.swift
index 55a9986..1e917b4 100644
--- a/Tests/SwiftFindRefs/FileSystem/FileSystemTests.swift
+++ b/Tests/SwiftFindRefs/FileSystem/FileSystemTests.swift
@@ -106,11 +106,61 @@ struct FileSystemTests {
#expect(error == .sample)
}
+ @Test("test readFile returns file contents")
+ func test_readFile_ReturnsContents() throws {
+ // Given
+ let fileURL = makeTempFileURL()
+ let contents = "Hello\nWorld"
+ try contents.write(to: fileURL, atomically: true, encoding: .utf8)
+ let sut = makeSUT(fileManager: FileManager.default)
+
+ // When
+ let result = try sut.readFile(atPath: fileURL.path)
+
+ // Then
+ #expect(result == contents)
+ }
+
+ @Test("test writeFile writes contents to disk")
+ func test_writeFile_WritesContents() throws {
+ // Given
+ let fileURL = makeTempFileURL()
+ let contents = "Line1\nLine2"
+ let sut = makeSUT(fileManager: FileManager.default)
+
+ // When
+ try sut.writeFile(contents, toPath: fileURL.path)
+
+ // Then
+ let result = try String(contentsOf: fileURL)
+ #expect(result == contents)
+ }
+
+ @Test("test readLines returns lines asynchronously")
+ func test_readLines_ReturnsLines() async 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)
+
+ // Then
+ #expect(lines == ["LineA", "LineB", "LineC"])
+ }
+
// MARK: - Helpers
private func makeSUT(fileManager: FileManager) -> FileSystem {
FileSystem(fileManager: fileManager)
}
+
+ private func makeTempFileURL() -> URL {
+ let directoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
+ return directoryURL.appendingPathComponent("SwiftFindRefs-\(UUID().uuidString).txt")
+ }
}
// MARK: - Test Doubles
diff --git a/Tests/SwiftFindRefs/IndexStore/IndexStoreFinderTests.swift b/Tests/SwiftFindRefs/IndexStore/IndexStoreFinderTests.swift
index 7a549f7..a113ccf 100644
--- a/Tests/SwiftFindRefs/IndexStore/IndexStoreFinderTests.swift
+++ b/Tests/SwiftFindRefs/IndexStore/IndexStoreFinderTests.swift
@@ -303,10 +303,29 @@ private struct MockSymbol: SymbolMatching, Sendable {
private struct MockSymbolOccurrence: SymbolOccurrenceProviding, Sendable {
let symbol: MockSymbol
-
+ let roles: SymbolRoles
+ let locationLine: Int
+ let symbolUSR: String
+
+ init(
+ symbol: MockSymbol,
+ roles: SymbolRoles = [],
+ locationLine: Int = 1,
+ symbolUSR: String = "mock.usr"
+ ) {
+ self.symbol = symbol
+ self.roles = roles
+ self.locationLine = locationLine
+ self.symbolUSR = symbolUSR
+ }
+
var symbolMatching: SymbolMatching {
symbol
}
+
+ func forEachRelatedSymbol(_ callback: (RelatedSymbolProviding, SymbolRoles) -> Void) {
+ _ = callback
+ }
}
private struct MockRecordReader: RecordReaderProviding, Sendable {
@@ -326,7 +345,24 @@ private struct MockUnitDependency: UnitDependencyProviding, Sendable {
private struct MockUnitReader: UnitReaderProviding, Sendable {
let isSystem: Bool
let dependencies: [MockUnitDependency]
-
+ let mainFile: String
+ let moduleName: String
+ let recordName: String?
+
+ init(
+ isSystem: Bool,
+ dependencies: [MockUnitDependency],
+ mainFile: String = "/mock/file.swift",
+ moduleName: String = "MockModule",
+ recordName: String? = "mock-record"
+ ) {
+ self.isSystem = isSystem
+ self.dependencies = dependencies
+ self.mainFile = mainFile
+ self.moduleName = moduleName
+ self.recordName = recordName
+ }
+
func forEachDependency(_ callback: (UnitDependencyProviding) -> Void) {
dependencies.forEach { callback($0) }
}
diff --git a/Tests/SwiftFindRefs/IndexStore/RecordIndexTests.swift b/Tests/SwiftFindRefs/IndexStore/RecordIndexTests.swift
index 5c9fb1c..d9e77ce 100644
--- a/Tests/SwiftFindRefs/IndexStore/RecordIndexTests.swift
+++ b/Tests/SwiftFindRefs/IndexStore/RecordIndexTests.swift
@@ -278,6 +278,23 @@ private struct MockUnitDependency: UnitDependencyProviding {
private struct MockUnitReader: UnitReaderProviding {
let isSystem: Bool
let dependencies: [MockUnitDependency]
+ let mainFile: String
+ let moduleName: String
+ let recordName: String?
+
+ init(
+ isSystem: Bool,
+ dependencies: [MockUnitDependency],
+ mainFile: String = "/mock/file.swift",
+ moduleName: String = "MockModule",
+ recordName: String? = "mock-record"
+ ) {
+ self.isSystem = isSystem
+ self.dependencies = dependencies
+ self.mainFile = mainFile
+ self.moduleName = moduleName
+ self.recordName = recordName
+ }
func forEachDependency(_ callback: (UnitDependencyProviding) -> Void) {
dependencies.forEach { callback($0) }
diff --git a/Tests/SwiftFindRefs/Mocks/MockFileSystem.swift b/Tests/SwiftFindRefs/Mocks/MockFileSystem.swift
index 3bbe43a..a694184 100644
--- a/Tests/SwiftFindRefs/Mocks/MockFileSystem.swift
+++ b/Tests/SwiftFindRefs/Mocks/MockFileSystem.swift
@@ -7,24 +7,37 @@ final class MockFileSystem: FileSystemProvider {
private let libraryDirectoryURL: URL
private let contentsOfDirectoryResults: [URL: [URL]]
private let contentsOfDirectoryError: Error?
+ private let readFileResults: [String: String]
+ private let readFileError: Error?
+ private let writeFileError: Error?
var actions: [Action] = []
+ var writtenFiles: [String: String] = [:]
enum Action: Equatable {
case fileExists(atPath: String)
case libraryDirectory
case contentsOfDirectory(at: URL, includingPropertiesForKeys: [URLResourceKey])
+ case readFile(atPath: String)
+ case readLines(atPath: String)
+ case writeFile(atPath: String, contents: String)
}
init(
fileExistsResults: [String: Bool] = [:],
libraryDirectoryURL: URL = URL(fileURLWithPath: "/mock/library/directory"),
contentsOfDirectoryResults: [URL: [URL]] = [:],
- contentsOfDirectoryError: Error? = nil
+ contentsOfDirectoryError: Error? = nil,
+ readFileResults: [String: String] = [:],
+ readFileError: Error? = nil,
+ writeFileError: Error? = nil
) {
self.fileExistsResults = fileExistsResults
self.libraryDirectoryURL = libraryDirectoryURL
self.contentsOfDirectoryResults = contentsOfDirectoryResults
self.contentsOfDirectoryError = contentsOfDirectoryError
+ self.readFileResults = readFileResults
+ self.readFileError = readFileError
+ self.writeFileError = writeFileError
}
func fileExists(atPath path: String) -> Bool {
@@ -48,4 +61,29 @@ final class MockFileSystem: FileSystemProvider {
}
return contentsOfDirectoryResults[url] ?? []
}
+
+ func readFile(atPath path: String) throws -> String {
+ actions.append(.readFile(atPath: path))
+ if let error = readFileError {
+ throw error
+ }
+ return readFileResults[path] ?? ""
+ }
+
+ func readLines(atPath path: String) async throws -> [String] {
+ actions.append(.readLines(atPath: path))
+ if let error = readFileError {
+ throw error
+ }
+ let contents = readFileResults[path] ?? ""
+ return contents.components(separatedBy: .newlines)
+ }
+
+ func writeFile(_ contents: String, toPath path: String) throws {
+ actions.append(.writeFile(atPath: path, contents: contents))
+ if let error = writeFileError {
+ throw error
+ }
+ writtenFiles[path] = contents
+ }
}
diff --git a/Tests/SwiftFindRefs/RemoveUnnecessaryTestable/TestableImportExtractorTests.swift b/Tests/SwiftFindRefs/RemoveUnnecessaryTestable/TestableImportExtractorTests.swift
new file mode 100644
index 0000000..463a3f6
--- /dev/null
+++ b/Tests/SwiftFindRefs/RemoveUnnecessaryTestable/TestableImportExtractorTests.swift
@@ -0,0 +1,81 @@
+import Testing
+@testable import SwiftFindRefs
+
+@Suite("TestableImportExtractor Tests")
+struct TestableImportExtractorTests {
+ @Test("extracts @testable imports from file")
+ func test_extractsTestableImports() async throws {
+ // Given
+ let filePath = "/mock/test.swift"
+ let contents = """
+ import Foundation
+ @testable import ModuleA
+ @testable import ModuleB
+ import ModuleC
+ """
+ let fileSystem = MockFileSystem(readFileResults: [filePath: contents])
+ let sut = TestableImportExtractor(
+ fileSystem: fileSystem,
+ excludeCompilationConditionals: false
+ )
+
+ // When
+ let imports = try await sut.testableImports(inFile: filePath)
+
+ // Then
+ #expect(imports == ["ModuleA", "ModuleB"])
+ }
+
+ @Test("excludes @testable imports inside compilation conditionals when enabled")
+ func test_excludesConditionalTestableImports() async throws {
+ // Given
+ let filePath = "/mock/conditional.swift"
+ let contents = """
+ #if Stoiximan
+ @testable import Stoiximan
+ #elseif Betano
+ @testable import Betano
+ #else
+ @testable import BetanoCasinoBE
+ #endif
+ @testable import AlwaysIncluded
+ """
+ let fileSystem = MockFileSystem(readFileResults: [filePath: contents])
+ let sut = TestableImportExtractor(
+ fileSystem: fileSystem,
+ excludeCompilationConditionals: true
+ )
+
+ // When
+ let imports = try await sut.testableImports(inFile: filePath)
+
+ // Then
+ #expect(imports == ["AlwaysIncluded"])
+ }
+
+ @Test("includes @testable imports inside compilation conditionals when disabled")
+ func test_includesConditionalTestableImportsWhenDisabled() async throws {
+ // Given
+ let filePath = "/mock/conditional.swift"
+ let contents = """
+ #if Stoiximan
+ @testable import Stoiximan
+ #elseif Betano
+ @testable import Betano
+ #else
+ @testable import BetanoCasinoBE
+ #endif
+ """
+ let fileSystem = MockFileSystem(readFileResults: [filePath: contents])
+ let sut = TestableImportExtractor(
+ fileSystem: fileSystem,
+ excludeCompilationConditionals: false
+ )
+
+ // When
+ let imports = try await sut.testableImports(inFile: filePath)
+
+ // Then
+ #expect(imports == ["Stoiximan", "Betano", "BetanoCasinoBE"])
+ }
+}
diff --git a/Tests/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableAnalyzerTests.swift b/Tests/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableAnalyzerTests.swift
new file mode 100644
index 0000000..66ead07
--- /dev/null
+++ b/Tests/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableAnalyzerTests.swift
@@ -0,0 +1,307 @@
+import Foundation
+import IndexStore
+import Testing
+@testable import SwiftFindRefs
+
+@Suite("UnnecessaryTestableAnalyzer Tests")
+struct UnnecessaryTestableAnalyzerTests {
+ @Test("analyze returns unnecessary modules when referenced definitions are public")
+ func test_analyze_ReturnsUnnecessaryModulesWhenDefinitionsArePublic() async throws {
+ // Given
+ let appFile = "/app/AppTests.swift"
+ let moduleFile = "/modules/ModuleA.swift"
+ let fileSystem = MockFileSystem(readFileResults: [
+ appFile: "@testable import ModuleA\n",
+ moduleFile: "public class Foo {}\n"
+ ])
+ let store = MockIndexStore(
+ units: [
+ MockUnitReader(mainFile: appFile, moduleName: "App", recordName: "app-record"),
+ MockUnitReader(mainFile: moduleFile, moduleName: "ModuleA", recordName: "module-record")
+ ],
+ recordReaders: [
+ "app-record": MockRecordReader(occurrences: [
+ MockSymbolOccurrence(
+ symbol: MockSymbol(name: "Foo", kind: .class),
+ roles: [.reference],
+ locationLine: 1,
+ symbolUSR: "usr.foo"
+ )
+ ]),
+ "module-record": MockRecordReader(occurrences: [
+ MockSymbolOccurrence(
+ symbol: MockSymbol(name: "Foo", kind: .class),
+ roles: [.definition],
+ locationLine: 1,
+ symbolUSR: "usr.foo"
+ )
+ ])
+ ]
+ )
+ let extractor = MockTestableImportExtractor(resultsByFile: [
+ appFile: ["ModuleA"]
+ ])
+ let sut = UnnecessaryTestableAnalyzer(
+ fileSystem: fileSystem,
+ extractor: extractor
+ )
+
+ // When
+ let result = try await sut.analyze(store: store, indexStorePath: "/index")
+
+ // Then
+ #expect(result == [appFile: ["ModuleA"]])
+ }
+
+ @Test("analyze keeps testable imports for internal definitions")
+ func test_analyze_KeepsTestableImportsWhenDefinitionsAreInternal() async throws {
+ // Given
+ let appFile = "/app/AppTests.swift"
+ let moduleFile = "/modules/ModuleA.swift"
+ let fileSystem = MockFileSystem(readFileResults: [
+ appFile: "@testable import ModuleA\n",
+ moduleFile: "class Foo {}\n"
+ ])
+ let store = MockIndexStore(
+ units: [
+ MockUnitReader(mainFile: appFile, moduleName: "App", recordName: "app-record"),
+ MockUnitReader(mainFile: moduleFile, moduleName: "ModuleA", recordName: "module-record")
+ ],
+ recordReaders: [
+ "app-record": MockRecordReader(occurrences: [
+ MockSymbolOccurrence(
+ symbol: MockSymbol(name: "Foo", kind: .class),
+ roles: [.reference],
+ locationLine: 1,
+ symbolUSR: "usr.foo"
+ )
+ ]),
+ "module-record": MockRecordReader(occurrences: [
+ MockSymbolOccurrence(
+ symbol: MockSymbol(name: "Foo", kind: .class),
+ roles: [.definition],
+ locationLine: 1,
+ symbolUSR: "usr.foo"
+ )
+ ])
+ ]
+ )
+ let extractor = MockTestableImportExtractor(resultsByFile: [
+ appFile: ["ModuleA"]
+ ])
+ let sut = UnnecessaryTestableAnalyzer(
+ fileSystem: fileSystem,
+ extractor: extractor
+ )
+
+ // When
+ let result = try await sut.analyze(store: store, indexStorePath: "/index")
+
+ // Then
+ #expect(result.isEmpty)
+ }
+
+ @Test("analyze throws missingModuleInIndex when testable module is not indexed")
+ func test_analyze_ThrowsMissingModuleInIndexWhenModuleNotIndexed() async {
+ // Given
+ let appFile = "/app/AppTests.swift"
+ let fileSystem = MockFileSystem(readFileResults: [
+ appFile: "@testable import ModuleA\n"
+ ])
+ let store = MockIndexStore(
+ units: [
+ MockUnitReader(mainFile: appFile, moduleName: "App", recordName: "app-record")
+ ],
+ recordReaders: [
+ "app-record": MockRecordReader(occurrences: [])
+ ]
+ )
+ let extractor = MockTestableImportExtractor(resultsByFile: [
+ appFile: ["ModuleA"]
+ ])
+ let sut = UnnecessaryTestableAnalyzer(
+ fileSystem: fileSystem,
+ extractor: extractor
+ )
+
+ // When
+ let error = await #expect(throws: UnnecessaryTestableError.self) {
+ _ = try await sut.analyze(store: store, indexStorePath: "/index")
+ }
+
+ // Then
+ guard case .missingModuleInIndex(let file, let modules) = error else {
+ Issue.record("Expected missingModuleInIndex but got \(error)")
+ return
+ }
+ #expect(file == appFile)
+ #expect(modules == ["ModuleA"])
+ }
+
+ @Test("analyze keeps first record when multiple units share the same main file")
+ func test_analyze_KeepsFirstRecordWhenUnitsShareMainFile() async throws {
+ // Given
+ let appFile = "/app/AppTests.swift"
+ let moduleFile = "/modules/ModuleA.swift"
+ let fileSystem = MockFileSystem(readFileResults: [
+ appFile: "@testable import ModuleA\n",
+ moduleFile: "public class Foo {}\n"
+ ])
+ let store = MockIndexStore(
+ units: [
+ MockUnitReader(mainFile: appFile, moduleName: "App", recordName: "record-1"),
+ MockUnitReader(mainFile: appFile, moduleName: "App", recordName: "record-2"),
+ MockUnitReader(mainFile: moduleFile, moduleName: "ModuleA", recordName: "module-record")
+ ],
+ recordReaders: [
+ "record-1": MockRecordReader(occurrences: [
+ MockSymbolOccurrence(
+ symbol: MockSymbol(name: "Foo", kind: .class),
+ roles: [.reference],
+ locationLine: 1,
+ symbolUSR: "usr.foo"
+ )
+ ]),
+ "record-2": MockRecordReader(occurrences: []),
+ "module-record": MockRecordReader(occurrences: [
+ MockSymbolOccurrence(
+ symbol: MockSymbol(name: "Foo", kind: .class),
+ roles: [.definition],
+ locationLine: 1,
+ symbolUSR: "usr.foo"
+ )
+ ])
+ ]
+ )
+ let extractor = MockTestableImportExtractor(resultsByFile: [
+ appFile: ["ModuleA"]
+ ])
+ let sut = UnnecessaryTestableAnalyzer(
+ fileSystem: fileSystem,
+ extractor: extractor
+ )
+
+ // When
+ let result = try await sut.analyze(store: store, indexStorePath: "/index")
+
+ // Then
+ #expect(result == [appFile: ["ModuleA"]])
+ }
+
+ @Test("analyze throws failedToLoadUnits when index store has no units")
+ func test_analyze_ThrowsFailedToLoadUnitsWhenNoUnits() async {
+ // Given
+ let fileSystem = MockFileSystem()
+ let store = MockIndexStore(units: [], recordReaders: [:])
+ let extractor = MockTestableImportExtractor(resultsByFile: [:])
+ let sut = UnnecessaryTestableAnalyzer(
+ fileSystem: fileSystem,
+ extractor: extractor
+ )
+
+ // When
+ let error = await #expect(throws: UnnecessaryTestableError.self) {
+ _ = try await sut.analyze(store: store, indexStorePath: "/index")
+ }
+
+ // Then
+ guard case .failedToLoadUnits(let path) = error else {
+ Issue.record("Expected failedToLoadUnits but got \(error)")
+ return
+ }
+ #expect(path == "/index")
+ }
+}
+
+// MARK: - Test Doubles
+
+private struct MockSymbol: SymbolMatching, Sendable {
+ let name: String
+ let kind: SymbolKind
+}
+
+private struct MockRelatedSymbol: RelatedSymbolProviding, Sendable {
+ let kind: SymbolKind
+}
+
+private struct MockSymbolOccurrence: SymbolOccurrenceProviding, Sendable {
+ let symbol: MockSymbol
+ let roles: SymbolRoles
+ let locationLine: Int
+ let symbolUSR: String
+ let relatedSymbols: [(MockRelatedSymbol, SymbolRoles)]
+
+ init(
+ symbol: MockSymbol,
+ roles: SymbolRoles = [],
+ locationLine: Int = 1,
+ symbolUSR: String = "mock.usr",
+ relatedSymbols: [(MockRelatedSymbol, SymbolRoles)] = []
+ ) {
+ self.symbol = symbol
+ self.roles = roles
+ self.locationLine = locationLine
+ self.symbolUSR = symbolUSR
+ self.relatedSymbols = relatedSymbols
+ }
+
+ var symbolMatching: SymbolMatching {
+ symbol
+ }
+
+ func forEachRelatedSymbol(_ callback: (RelatedSymbolProviding, SymbolRoles) -> Void) {
+ relatedSymbols.forEach { callback($0.0, $0.1) }
+ }
+}
+
+private struct MockRecordReader: RecordReaderProviding, Sendable {
+ let occurrences: [MockSymbolOccurrence]
+
+ func forEachOccurrence(_ callback: (SymbolOccurrenceProviding) -> Void) {
+ occurrences.forEach { callback($0) }
+ }
+}
+
+private struct MockUnitReader: UnitReaderProviding, Sendable {
+ let isSystem: Bool
+ let mainFile: String
+ let moduleName: String
+ let recordName: String?
+
+ init(
+ isSystem: Bool = false,
+ mainFile: String,
+ moduleName: String,
+ recordName: String?
+ ) {
+ self.isSystem = isSystem
+ self.mainFile = mainFile
+ self.moduleName = moduleName
+ self.recordName = recordName
+ }
+
+ func forEachDependency(_ callback: (UnitDependencyProviding) -> Void) {
+ _ = callback
+ }
+}
+
+private struct MockIndexStore: IndexStoreProviding, Sendable {
+ let units: [MockUnitReader]
+ let recordReaders: [String: MockRecordReader]
+
+ func forEachUnit(_ callback: (UnitReaderProviding) -> Void) {
+ units.forEach { callback($0) }
+ }
+
+ func recordReader(for recordName: String) throws -> RecordReaderProviding? {
+ recordReaders[recordName]
+ }
+}
+
+private struct MockTestableImportExtractor: TestableImportExtracting, Sendable {
+ let resultsByFile: [String: Set]
+
+ func testableImports(inFile path: String) async throws -> Set {
+ resultsByFile[path] ?? []
+ }
+}
diff --git a/Tests/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableErrorTests.swift b/Tests/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableErrorTests.swift
new file mode 100644
index 0000000..3104f59
--- /dev/null
+++ b/Tests/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableErrorTests.swift
@@ -0,0 +1,45 @@
+import Testing
+@testable import SwiftFindRefs
+
+@Suite("UnnecessaryTestableError Tests")
+struct UnnecessaryTestableErrorTests {
+ @Test("failedToOpenIndexStore errorDescription includes path")
+ func test_failedToOpenIndexStore_ErrorDescription() {
+ let path = "/mock/index"
+ let error = UnnecessaryTestableError.failedToOpenIndexStore(path)
+ #expect(error.errorDescription == "Failed to open index store at \(path).")
+ }
+
+ @Test("failedToLoadUnits errorDescription includes path")
+ func test_failedToLoadUnits_ErrorDescription() {
+ let path = "/mock/index"
+ let error = UnnecessaryTestableError.failedToLoadUnits(path)
+ #expect(error.errorDescription == "Failed to load units from index store at \(path).")
+ }
+
+ @Test("duplicateRecord errorDescription includes file")
+ func test_duplicateRecord_ErrorDescription() {
+ let file = "/mock/File.swift"
+ let error = UnnecessaryTestableError.duplicateRecord(file)
+ #expect(error.errorDescription == "Found duplicate record for \(file).")
+ }
+
+ @Test("missingModuleInIndex errorDescription includes file and modules")
+ func test_missingModuleInIndex_ErrorDescription() {
+ let file = "/mock/File.swift"
+ let modules: Set = ["ModuleA", "ModuleB"]
+ let error = UnnecessaryTestableError.missingModuleInIndex(file: file, modules: modules)
+ #expect(
+ error.errorDescription ==
+ "Some modules imported with @testable were not included in the index \(file): \(modules)"
+ )
+ }
+
+ @Test("missingSourceLine errorDescription includes file and line")
+ func test_missingSourceLine_ErrorDescription() {
+ let file = "/mock/File.swift"
+ let line = 42
+ let error = UnnecessaryTestableError.missingSourceLine(file: file, line: line)
+ #expect(error.errorDescription == "Could not read line \(line) in \(file).")
+ }
+}
diff --git a/Tests/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableRemoverTests.swift b/Tests/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableRemoverTests.swift
new file mode 100644
index 0000000..02a3344
--- /dev/null
+++ b/Tests/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableRemoverTests.swift
@@ -0,0 +1,109 @@
+import Testing
+@testable import SwiftFindRefs
+
+@Suite("UnnecessaryTestableRemover Tests")
+struct UnnecessaryTestableRemoverTests {
+ @Test("run uses analyzer and rewriter and returns updated files")
+ func test_run_UsesAnalyzerAndRewriter() async throws {
+ // Given
+ let indexStorePath = "/mock/index"
+ let analyzer = MockAnalyzer(result: ["/mock/Test.swift": ["ModuleA"]])
+ let rewriter = MockRewriter(result: ["/mock/Test.swift"])
+ var messages: [String] = []
+ let sut = UnnecessaryTestableRemover(
+ indexStorePath: indexStorePath,
+ print: { messages.append($0) },
+ storeFactory: { DummyStore() },
+ analyzer: analyzer,
+ rewriter: rewriter
+ )
+
+ // When
+ let result = try await sut.run()
+
+ // Then
+ #expect(result == ["/mock/Test.swift"])
+ #expect(analyzer.calls == 1)
+ #expect(analyzer.lastIndexStorePath == indexStorePath)
+ #expect(rewriter.calls == 1)
+ #expect(rewriter.lastRemovals == ["/mock/Test.swift": ["ModuleA"]])
+ #expect(messages.contains("Removed unnecessary @testable imports from 1 files."))
+ }
+
+ @Test("run throws failedToOpenIndexStore when storeFactory throws")
+ func test_run_WhenStoreFactoryThrows_throwsFailedToOpenIndexStore() async {
+ // Given
+ let indexStorePath = "/mock/index"
+ let analyzer = MockAnalyzer(result: [:])
+ let rewriter = MockRewriter(result: [])
+ let sut = UnnecessaryTestableRemover(
+ indexStorePath: indexStorePath,
+ print: { _ in },
+ storeFactory: { throw TestError.sample },
+ analyzer: analyzer,
+ rewriter: rewriter
+ )
+
+ // When
+ let error = await #expect(throws: UnnecessaryTestableError.self) {
+ _ = try await sut.run()
+ }
+
+ // Then
+ guard case .failedToOpenIndexStore(let path) = error else {
+ Issue.record("Expected failedToOpenIndexStore but got \(error)")
+ return
+ }
+ #expect(path == indexStorePath)
+ #expect(analyzer.calls == 0)
+ #expect(rewriter.calls == 0)
+ }
+}
+
+private enum TestError: Error {
+ case sample
+}
+
+private struct DummyStore: IndexStoreProviding {
+ func forEachUnit(_ callback: (UnitReaderProviding) -> Void) {
+ _ = callback
+ }
+
+ func recordReader(for recordName: String) throws -> RecordReaderProviding? {
+ _ = recordName
+ return nil
+ }
+}
+
+private final class MockAnalyzer: UnnecessaryTestableAnalyzing {
+ private let result: [String: Set]
+ private(set) var calls = 0
+ private(set) var lastIndexStorePath: String?
+
+ init(result: [String: Set]) {
+ self.result = result
+ }
+
+ func analyze(store: some IndexStoreProviding, indexStorePath: String) async throws -> [String: Set] {
+ _ = store
+ calls += 1
+ lastIndexStorePath = indexStorePath
+ return result
+ }
+}
+
+private final class MockRewriter: UnnecessaryTestableRewriting {
+ private let result: [String]
+ private(set) var calls = 0
+ private(set) var lastRemovals: [String: Set]?
+
+ init(result: [String]) {
+ self.result = result
+ }
+
+ func rewriteFiles(_ removalsByFile: [String: Set]) async throws -> [String] {
+ calls += 1
+ lastRemovals = removalsByFile
+ return result
+ }
+}
diff --git a/Tests/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableRewriterTests.swift b/Tests/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableRewriterTests.swift
new file mode 100644
index 0000000..cbbb43f
--- /dev/null
+++ b/Tests/SwiftFindRefs/RemoveUnnecessaryTestable/UnnecessaryTestableRewriterTests.swift
@@ -0,0 +1,48 @@
+import Testing
+@testable import SwiftFindRefs
+
+@Suite("UnnecessaryTestableRewriter Tests")
+struct UnnecessaryTestableRewriterTests {
+ @Test("rewrites @testable imports in-place")
+ func test_rewritesTestableImports() async throws {
+ // Given
+ let filePath = "/mock/Test.swift"
+ let contents = """
+ @testable import ModuleA
+ import ModuleB
+ @testable import ModuleC
+ """
+ let fileSystem = MockFileSystem(readFileResults: [filePath: contents])
+ 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])
+ #expect(written.contains("import ModuleA"))
+ #expect(written.contains("import ModuleC"))
+ #expect(!written.contains("@testable import ModuleA"))
+ #expect(!written.contains("@testable import ModuleC"))
+ }
+
+ @Test("skips rewrite when no changes needed")
+ func test_skipsRewriteWhenNoChanges() async throws {
+ // Given
+ let filePath = "/mock/Test.swift"
+ let contents = """
+ import ModuleA
+ import ModuleB
+ """
+ let fileSystem = MockFileSystem(readFileResults: [filePath: contents])
+ let sut = UnnecessaryTestableRewriter(fileSystem: fileSystem, print: { _ in })
+
+ // When
+ let updated = try await sut.rewriteFiles([filePath: ["ModuleC"]])
+
+ // Then
+ #expect(updated.isEmpty)
+ #expect(fileSystem.writtenFiles.isEmpty)
+ }
+}
diff --git a/swiftfindrefs/SKILL.md b/swiftfindrefs/SKILL.md
index 20083c9..5b9efe5 100644
--- a/swiftfindrefs/SKILL.md
+++ b/swiftfindrefs/SKILL.md
@@ -32,7 +32,7 @@ swiftfindrefs \
Optional flags:
- `--derivedDataPath `: explicit DerivedData (or IndexStoreDB) path; skips discovery
-- `--verbose`: prints discovery steps, resolved paths, and diagnostics
+- `-v, --verbose`: enables verbose output for diagnostic purposes (flag, no value required)
## Output contract
- One absolute file path per line
diff --git a/swiftfindrefs/references/cli.md b/swiftfindrefs/references/cli.md
index c230e30..0bbf1d6 100644
--- a/swiftfindrefs/references/cli.md
+++ b/swiftfindrefs/references/cli.md
@@ -12,15 +12,16 @@
- `-d, --derivedDataPath`
Points directly to a DerivedData (or IndexStoreDB) directory and skips discovery.
+- `-v, --verbose` (flag, no value required)
+ Enables verbose output for diagnostic purposes.
+
+## Search subcommand flags
- `-n, --symbolName` (required)
The symbol to inspect.
- `-t, --symbolType`
Narrows matches to a specific kind (recommended when possible).
-- `-v, --verbose`
- Prints discovery steps, resolved paths, and diagnostics.
-
## Recommended invocations
Most common:
diff --git a/swiftfindrefs/references/troubleshooting.md b/swiftfindrefs/references/troubleshooting.md
index 01f1331..8d8af35 100644
--- a/swiftfindrefs/references/troubleshooting.md
+++ b/swiftfindrefs/references/troubleshooting.md
@@ -10,7 +10,7 @@
## Wrong DerivedData selected
- Prefer explicit `--derivedDataPath` in CI or multi-clone setups.
-- Use `--verbose` to confirm path selection.
+- Use `-v` or `--verbose` flag to confirm path selection.
## Do not fall back to grep
- Text search is not acceptable for reference discovery.