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
70 changes: 65 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,15 @@
</p>

# 🔎 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
Expand All @@ -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 \
Expand All @@ -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)

Expand Down
28 changes: 28 additions & 0 deletions Sources/SwiftFindRefs/CompositionRoot/RemoveCompositionRoot.swift
Original file line number Diff line number Diff line change
@@ -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) }
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Foundation

struct CompositionRoot {
struct SearchCompositionRoot {
let projectName: String?
let derivedDataPath: String?
let symbolName: String
Expand All @@ -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"))")
}
}
17 changes: 17 additions & 0 deletions Sources/SwiftFindRefs/FileSystem/FileSystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
14 changes: 14 additions & 0 deletions Sources/SwiftFindRefs/FileSystem/FileSystemProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
16 changes: 16 additions & 0 deletions Sources/SwiftFindRefs/IndexStore/IndexStore+Providing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
12 changes: 12 additions & 0 deletions Sources/SwiftFindRefs/IndexStore/IndexStoreProviding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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 }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
protocol TestableImportExtracting {
func testableImports(inFile path: String) async throws -> Set<String>
}
Original file line number Diff line number Diff line change
@@ -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<String> {
let lines = try await fileSystem.readLines(atPath: path)
var testableImports = Set<String>()
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
}
}
Loading