diff --git a/README.md b/README.md
index ab68253..34e5d98 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
# 🔎 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.
+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.
## 🚀 Common use case
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.
diff --git a/Sources/SwiftFindRefs/IndexStore/IndexStore+Providing.swift b/Sources/SwiftFindRefs/IndexStore/IndexStore+Providing.swift
new file mode 100644
index 0000000..94205d5
--- /dev/null
+++ b/Sources/SwiftFindRefs/IndexStore/IndexStore+Providing.swift
@@ -0,0 +1,39 @@
+import IndexStore
+
+// MARK: - IndexStore Library Conformance
+
+extension UnitDependency: UnitDependencyProviding {}
+
+extension UnitReader: UnitReaderProviding {
+ func forEachDependency(_ callback: (UnitDependencyProviding) -> Void) {
+ forEach { dependency in
+ callback(dependency)
+ }
+ }
+}
+
+extension IndexStore: IndexStoreProviding {
+ func forEachUnit(_ callback: (UnitReaderProviding) -> Void) {
+ for unit in units {
+ callback(unit)
+ }
+ }
+
+ func recordReader(for recordName: String) throws -> RecordReaderProviding? {
+ try? RecordReader(indexStore: self, recordName: recordName)
+ }
+}
+
+extension RecordReader: RecordReaderProviding {
+ func forEachOccurrence(_ callback: (SymbolOccurrenceProviding) -> Void) {
+ forEach { occurrence in
+ callback(occurrence)
+ }
+ }
+}
+
+extension SymbolOccurrence: SymbolOccurrenceProviding {
+ var symbolMatching: SymbolMatching {
+ symbol
+ }
+}
diff --git a/Sources/SwiftFindRefs/IndexStore/IndexStoreFinder.swift b/Sources/SwiftFindRefs/IndexStore/IndexStoreFinder.swift
index 2f7e6fe..e08a101 100644
--- a/Sources/SwiftFindRefs/IndexStore/IndexStoreFinder.swift
+++ b/Sources/SwiftFindRefs/IndexStore/IndexStoreFinder.swift
@@ -1,91 +1,78 @@
import Foundation
-import IndexStore
+@preconcurrency import IndexStore
struct IndexStoreFinder {
let indexStorePath: String
func fileReferences(of symbolName: String, symbolType: String?) throws -> [String] {
let store = try IndexStore(path: indexStorePath)
-
- // Pre-compute SymbolKind enum to avoid string comparison in hot loop
- let expectedSymbolKind: SymbolKind? = symbolType.flatMap { parseSymbolKind($0) }
+ return try fileReferences(of: symbolName, symbolType: symbolType, from: store)
+ }
- // Collect all record dependencies with their source paths in a single pass
- var recordToSource: [String: String] = [:]
- var allRecordNames = Set()
+ func fileReferences(
+ of symbolName: String,
+ symbolType: String?,
+ from store: some IndexStoreProviding & Sendable
+ ) throws -> [String] {
+ let query = SymbolQuery(name: symbolName, kindString: symbolType)
+ let index = RecordIndex.build(from: store)
- for unitReader in store.units {
- // Skip system frameworks (SDK headers, etc.)
- guard !unitReader.isSystem else { continue }
-
- unitReader.forEach { dependency in
- guard dependency.kind == .record else { return }
- let recordName = dependency.name
- allRecordNames.insert(recordName)
- // Only store non-empty paths, prefer keeping existing paths
- let filePath = dependency.filePath
- if !filePath.isEmpty && recordToSource[recordName] == nil {
- recordToSource[recordName] = filePath
- }
- }
- }
+ return searchRecordsInParallel(store: store, index: index, query: query)
+ }
- // Convert to array for parallel processing
- let recordNames = Array(allRecordNames)
- let lock = NSLock()
- var referencedFiles = Set()
+ private func searchRecordsInParallel(
+ store: some IndexStoreProviding & Sendable,
+ index: RecordIndex,
+ query: SymbolQuery
+ ) -> [String] {
+ let referencedFiles = ThreadSafeSet()
- // Process records in parallel across all CPU cores
- DispatchQueue.concurrentPerform(iterations: recordNames.count) { index in
- let recordName = recordNames[index]
- guard let recordReader = try? RecordReader(indexStore: store, recordName: recordName) else {
- return
- }
+ DispatchQueue.concurrentPerform(iterations: index.recordNames.count) { i in
+ let recordName = index.recordNames[i]
- var foundInRecord = false
- recordReader.forEach { (occurrence: SymbolOccurrence) in
- guard !foundInRecord else { return }
- guard occurrence.symbol.name == symbolName else { return }
- if let expectedKind = expectedSymbolKind, occurrence.symbol.kind != expectedKind {
- return
- }
- foundInRecord = true
- }
-
- if foundInRecord {
- let filename = recordToSource[recordName] ?? recordName
- lock.lock()
+ if recordContainsSymbol(store: store, recordName: recordName, query: query) {
+ let filename = index.sourcePath(for: recordName)
referencedFiles.insert(filename)
- lock.unlock()
}
}
- return referencedFiles.sorted()
+ return referencedFiles.values().sorted()
}
-
- private func parseSymbolKind(_ type: String) -> SymbolKind? {
- switch type.lowercased() {
- case "class": return .class
- case "struct": return .struct
- case "enum": return .enum
- case "protocol": return .protocol
- case "function": return .function
- case "variable": return .variable
- case "typealias": return .typealias
- case "instancemethod": return .instanceMethod
- case "staticmethod": return .staticMethod
- case "classmethod": return .classMethod
- case "instanceproperty": return .instanceProperty
- case "staticproperty": return .staticProperty
- case "classproperty": return .classProperty
- case "constructor": return .constructor
- case "destructor": return .destructor
- case "field": return .field
- case "enumconstant": return .enumConstant
- case "parameter": return .parameter
- case "module": return .module
- case "extension": return .extension
- default: return nil
+
+ private func recordContainsSymbol(
+ store: some IndexStoreProviding,
+ recordName: String,
+ query: SymbolQuery
+ ) -> Bool {
+ guard let recordReader = try? store.recordReader(for: recordName) else {
+ return false
}
+
+ var found = false
+ recordReader.forEachOccurrence { occurrence in
+ guard !found else { return }
+ if query.matches(occurrence.symbolMatching) {
+ found = true
+ }
+ }
+ return found
+ }
+}
+
+private final class ThreadSafeSet: @unchecked Sendable {
+ private let lock = NSLock()
+ private var storage = Set()
+
+ func insert(_ element: Element) {
+ lock.lock()
+ storage.insert(element)
+ lock.unlock()
+ }
+
+ func values() -> [Element] {
+ lock.lock()
+ let snapshot = Array(storage)
+ lock.unlock()
+ return snapshot
}
}
diff --git a/Sources/SwiftFindRefs/IndexStore/IndexStoreProviding.swift b/Sources/SwiftFindRefs/IndexStore/IndexStoreProviding.swift
new file mode 100644
index 0000000..ec184c1
--- /dev/null
+++ b/Sources/SwiftFindRefs/IndexStore/IndexStoreProviding.swift
@@ -0,0 +1,30 @@
+import IndexStore
+
+/// Protocol for unit dependency abstraction, enabling testability
+protocol UnitDependencyProviding {
+ var kind: DependencyKind { get }
+ var name: String { get }
+ var filePath: String { get }
+}
+
+/// Protocol for unit reader abstraction, enabling testability
+protocol UnitReaderProviding {
+ var isSystem: Bool { get }
+ func forEachDependency(_ callback: (UnitDependencyProviding) -> Void)
+}
+
+/// Protocol for index store abstraction, enabling testability
+protocol IndexStoreProviding {
+ func forEachUnit(_ callback: (UnitReaderProviding) -> Void)
+ func recordReader(for recordName: String) throws -> RecordReaderProviding?
+}
+
+/// Protocol for record reader abstraction, enabling testability
+protocol RecordReaderProviding {
+ func forEachOccurrence(_ callback: (SymbolOccurrenceProviding) -> Void)
+}
+
+/// Protocol for symbol occurrence abstraction, enabling testability
+protocol SymbolOccurrenceProviding {
+ var symbolMatching: SymbolMatching { get }
+}
diff --git a/Sources/SwiftFindRefs/IndexStore/RecordIndex.swift b/Sources/SwiftFindRefs/IndexStore/RecordIndex.swift
new file mode 100644
index 0000000..0c6a2d3
--- /dev/null
+++ b/Sources/SwiftFindRefs/IndexStore/RecordIndex.swift
@@ -0,0 +1,41 @@
+import IndexStore
+
+/// Maps record names to their source file paths
+struct RecordIndex {
+ let recordNames: [String]
+ private let recordToSource: [String: String]
+
+ /// Internal initializer for testing
+ init(recordNames: [String], recordToSource: [String: String] = [:]) {
+ self.recordNames = recordNames
+ self.recordToSource = recordToSource
+ }
+
+ func sourcePath(for recordName: String) -> String {
+ recordToSource[recordName] ?? recordName
+ }
+
+ static func build(from store: some IndexStoreProviding) -> RecordIndex {
+ var recordToSource: [String: String] = [:]
+ var allRecordNames = Set()
+
+ store.forEachUnit { unitReader in
+ guard !unitReader.isSystem else { return }
+
+ unitReader.forEachDependency { dependency in
+ guard dependency.kind == .record else { return }
+ let recordName = dependency.name
+ allRecordNames.insert(recordName)
+ let filePath = dependency.filePath
+ if !filePath.isEmpty && recordToSource[recordName] == nil {
+ recordToSource[recordName] = filePath
+ }
+ }
+ }
+
+ return RecordIndex(
+ recordNames: Array(allRecordNames),
+ recordToSource: recordToSource
+ )
+ }
+}
diff --git a/Sources/SwiftFindRefs/IndexStore/SymbolKind+Parsing.swift b/Sources/SwiftFindRefs/IndexStore/SymbolKind+Parsing.swift
new file mode 100644
index 0000000..1891d60
--- /dev/null
+++ b/Sources/SwiftFindRefs/IndexStore/SymbolKind+Parsing.swift
@@ -0,0 +1,29 @@
+import IndexStore
+
+extension SymbolKind {
+ init?(parsing type: String) {
+ switch type.lowercased() {
+ case "class": self = .class
+ case "struct": self = .struct
+ case "enum": self = .enum
+ case "protocol": self = .protocol
+ case "function": self = .function
+ case "variable": self = .variable
+ case "typealias": self = .typealias
+ case "instancemethod": self = .instanceMethod
+ case "staticmethod": self = .staticMethod
+ case "classmethod": self = .classMethod
+ case "instanceproperty": self = .instanceProperty
+ case "staticproperty": self = .staticProperty
+ case "classproperty": self = .classProperty
+ case "constructor": self = .constructor
+ case "destructor": self = .destructor
+ case "field": self = .field
+ case "enumconstant": self = .enumConstant
+ case "parameter": self = .parameter
+ case "module": self = .module
+ case "extension": self = .extension
+ default: return nil
+ }
+ }
+}
diff --git a/Sources/SwiftFindRefs/IndexStore/SymbolMatching.swift b/Sources/SwiftFindRefs/IndexStore/SymbolMatching.swift
new file mode 100644
index 0000000..00a0466
--- /dev/null
+++ b/Sources/SwiftFindRefs/IndexStore/SymbolMatching.swift
@@ -0,0 +1,9 @@
+import IndexStore
+
+/// Protocol for symbol matching, enabling testability
+protocol SymbolMatching {
+ var name: String { get }
+ var kind: SymbolKind { get }
+}
+
+extension Symbol: SymbolMatching {}
diff --git a/Sources/SwiftFindRefs/IndexStore/SymbolQuery.swift b/Sources/SwiftFindRefs/IndexStore/SymbolQuery.swift
new file mode 100644
index 0000000..a8e5387
--- /dev/null
+++ b/Sources/SwiftFindRefs/IndexStore/SymbolQuery.swift
@@ -0,0 +1,18 @@
+import IndexStore
+
+/// Encapsulates the search criteria for finding symbols
+struct SymbolQuery {
+ let name: String
+ let kind: SymbolKind?
+
+ init(name: String, kindString: String?) {
+ self.name = name
+ self.kind = kindString.flatMap { SymbolKind(parsing: $0) }
+ }
+
+ func matches(_ symbol: some SymbolMatching) -> Bool {
+ guard symbol.name == name else { return false }
+ if let kind, symbol.kind != kind { return false }
+ return true
+ }
+}
diff --git a/Tests/SwiftFindRefs/IndexStore/IndexStoreFinderTests.swift b/Tests/SwiftFindRefs/IndexStore/IndexStoreFinderTests.swift
index c79080a..b3bdaed 100644
--- a/Tests/SwiftFindRefs/IndexStore/IndexStoreFinderTests.swift
+++ b/Tests/SwiftFindRefs/IndexStore/IndexStoreFinderTests.swift
@@ -1,11 +1,12 @@
import Foundation
import Testing
+import IndexStore
@testable import SwiftFindRefs
@Suite("IndexStoreFinder Tests")
struct IndexStoreFinderTests {
- // MARK: - fileReferences
+ // MARK: - fileReferences with invalid path
@Test("test fileReferences with invalid path throws error")
func test_fileReferences_WithInvalidPath_throwsError() {
@@ -18,9 +19,328 @@ struct IndexStoreFinderTests {
}
}
+ // MARK: - fileReferences with mock store
+
+ @Test("test fileReferences with empty store returns empty array")
+ func test_fileReferences_WithEmptyStore_returnsEmptyArray() throws {
+ // Given
+ let sut = makeSUT()
+ let store = MockIndexStore(units: [], recordReaders: [:])
+
+ // When
+ let result = try sut.fileReferences(of: "SomeSymbol", symbolType: nil, from: store)
+
+ // Then
+ #expect(result.isEmpty)
+ }
+
+ @Test("test fileReferences with matching symbol returns file path")
+ func test_fileReferences_WithMatchingSymbol_returnsFilePath() throws {
+ // Given
+ let sut = makeSUT()
+ let symbolName = "MyClass"
+ let filePath = "/path/to/MyClass.swift"
+ let recordName = "MyClassRecord"
+
+ let store = MockIndexStore(
+ units: [
+ MockUnitReader(
+ isSystem: false,
+ dependencies: [
+ MockUnitDependency(kind: .record, name: recordName, filePath: filePath)
+ ]
+ )
+ ],
+ recordReaders: [
+ recordName: MockRecordReader(occurrences: [
+ MockSymbolOccurrence(symbol: MockSymbol(name: symbolName, kind: .class))
+ ])
+ ]
+ )
+
+ // When
+ let result = try sut.fileReferences(of: symbolName, symbolType: nil, from: store)
+
+ // Then
+ #expect(result.count == 1)
+ #expect(result.contains(filePath))
+ }
+
+ @Test("test fileReferences with non-matching symbol returns empty array")
+ func test_fileReferences_WithNonMatchingSymbol_returnsEmptyArray() throws {
+ // Given
+ let sut = makeSUT()
+ let recordName = "SomeRecord"
+
+ let store = MockIndexStore(
+ units: [
+ MockUnitReader(
+ isSystem: false,
+ dependencies: [
+ MockUnitDependency(kind: .record, name: recordName, filePath: "/path/to/file.swift")
+ ]
+ )
+ ],
+ recordReaders: [
+ recordName: MockRecordReader(occurrences: [
+ MockSymbolOccurrence(symbol: MockSymbol(name: "DifferentSymbol", kind: .class))
+ ])
+ ]
+ )
+
+ // When
+ let result = try sut.fileReferences(of: "MySymbol", symbolType: nil, from: store)
+
+ // Then
+ #expect(result.isEmpty)
+ }
+
+ @Test("test fileReferences with matching name but different kind returns empty array")
+ func test_fileReferences_WithMatchingNameButDifferentKind_returnsEmptyArray() throws {
+ // Given
+ let sut = makeSUT()
+ let symbolName = "Selection"
+ let recordName = "SelectionRecord"
+
+ let store = MockIndexStore(
+ units: [
+ MockUnitReader(
+ isSystem: false,
+ dependencies: [
+ MockUnitDependency(kind: .record, name: recordName, filePath: "/path/to/file.swift")
+ ]
+ )
+ ],
+ recordReaders: [
+ recordName: MockRecordReader(occurrences: [
+ MockSymbolOccurrence(symbol: MockSymbol(name: symbolName, kind: .struct))
+ ])
+ ]
+ )
+
+ // When
+ let result = try sut.fileReferences(of: symbolName, symbolType: "class", from: store)
+
+ // Then
+ #expect(result.isEmpty)
+ }
+
+ @Test("test fileReferences with nil symbol type matches any kind")
+ func test_fileReferences_WithNilSymbolType_matchesAnyKind() throws {
+ // Given
+ let sut = makeSUT()
+ let symbolName = "MySymbol"
+ let recordName = "MyRecord"
+ let filePath = "/path/to/file.swift"
+
+ let store = MockIndexStore(
+ units: [
+ MockUnitReader(
+ isSystem: false,
+ dependencies: [
+ MockUnitDependency(kind: .record, name: recordName, filePath: filePath)
+ ]
+ )
+ ],
+ recordReaders: [
+ recordName: MockRecordReader(occurrences: [
+ MockSymbolOccurrence(symbol: MockSymbol(name: symbolName, kind: .function))
+ ])
+ ]
+ )
+
+ // When
+ let result = try sut.fileReferences(of: symbolName, symbolType: nil, from: store)
+
+ // Then
+ #expect(result.count == 1)
+ #expect(result.contains(filePath))
+ }
+
+ @Test("test fileReferences with multiple matching files returns sorted paths")
+ func test_fileReferences_WithMultipleMatchingFiles_returnsSortedPaths() throws {
+ // Given
+ let sut = makeSUT()
+ let symbolName = "SharedProtocol"
+
+ let store = MockIndexStore(
+ units: [
+ MockUnitReader(
+ isSystem: false,
+ dependencies: [
+ MockUnitDependency(kind: .record, name: "Record1", filePath: "/z/path.swift"),
+ MockUnitDependency(kind: .record, name: "Record2", filePath: "/a/path.swift"),
+ MockUnitDependency(kind: .record, name: "Record3", filePath: "/m/path.swift")
+ ]
+ )
+ ],
+ recordReaders: [
+ "Record1": MockRecordReader(occurrences: [
+ MockSymbolOccurrence(symbol: MockSymbol(name: symbolName, kind: .protocol))
+ ]),
+ "Record2": MockRecordReader(occurrences: [
+ MockSymbolOccurrence(symbol: MockSymbol(name: symbolName, kind: .protocol))
+ ]),
+ "Record3": MockRecordReader(occurrences: [
+ MockSymbolOccurrence(symbol: MockSymbol(name: symbolName, kind: .protocol))
+ ])
+ ]
+ )
+
+ // When
+ let result = try sut.fileReferences(of: symbolName, symbolType: nil, from: store)
+
+ // Then
+ #expect(result.count == 3)
+ #expect(result == ["/a/path.swift", "/m/path.swift", "/z/path.swift"])
+ }
+
+ @Test("test fileReferences skips system units")
+ func test_fileReferences_WithSystemUnits_skipsThem() throws {
+ // Given
+ let sut = makeSUT()
+ let symbolName = "MyClass"
+
+ let store = MockIndexStore(
+ units: [
+ MockUnitReader(
+ isSystem: true,
+ dependencies: [
+ MockUnitDependency(kind: .record, name: "SystemRecord", filePath: "/system/path.swift")
+ ]
+ )
+ ],
+ recordReaders: [
+ "SystemRecord": MockRecordReader(occurrences: [
+ MockSymbolOccurrence(symbol: MockSymbol(name: symbolName, kind: .class))
+ ])
+ ]
+ )
+
+ // When
+ let result = try sut.fileReferences(of: symbolName, symbolType: nil, from: store)
+
+ // Then
+ #expect(result.isEmpty)
+ }
+
+ @Test("test fileReferences with unreadable record skips it")
+ func test_fileReferences_WithUnreadableRecord_skipsIt() throws {
+ // Given
+ let sut = makeSUT()
+ let symbolName = "MyClass"
+
+ let store = MockIndexStore(
+ units: [
+ MockUnitReader(
+ isSystem: false,
+ dependencies: [
+ MockUnitDependency(kind: .record, name: "UnreadableRecord", filePath: "/path.swift")
+ ]
+ )
+ ],
+ recordReaders: [:] // No record reader for "UnreadableRecord"
+ )
+
+ // When
+ let result = try sut.fileReferences(of: symbolName, symbolType: nil, from: store)
+
+ // Then
+ #expect(result.isEmpty)
+ }
+
+ @Test("test fileReferences deduplicates files when symbol appears multiple times")
+ func test_fileReferences_WithDuplicateRecords_deduplicatesFiles() throws {
+ // Given
+ let sut = makeSUT()
+ let symbolName = "MyClass"
+ let filePath = "/path/to/file.swift"
+ let recordName = "Record1"
+
+ let store = MockIndexStore(
+ units: [
+ MockUnitReader(
+ isSystem: false,
+ dependencies: [
+ MockUnitDependency(kind: .record, name: recordName, filePath: filePath)
+ ]
+ ),
+ MockUnitReader(
+ isSystem: false,
+ dependencies: [
+ MockUnitDependency(kind: .record, name: recordName, filePath: filePath)
+ ]
+ )
+ ],
+ recordReaders: [
+ recordName: MockRecordReader(occurrences: [
+ MockSymbolOccurrence(symbol: MockSymbol(name: symbolName, kind: .class))
+ ])
+ ]
+ )
+
+ // When
+ let result = try sut.fileReferences(of: symbolName, symbolType: nil, from: store)
+
+ // Then
+ #expect(result.count == 1)
+ #expect(result.contains(filePath))
+ }
+
// MARK: - Helpers
- private func makeSUT(indexStorePath: String) -> IndexStoreFinder {
+ private func makeSUT(indexStorePath: String = "/mock/path") -> IndexStoreFinder {
IndexStoreFinder(indexStorePath: indexStorePath)
}
}
+
+// MARK: - Test Doubles
+
+private struct MockSymbol: SymbolMatching {
+ let name: String
+ let kind: SymbolKind
+}
+
+private struct MockSymbolOccurrence: SymbolOccurrenceProviding {
+ let symbol: MockSymbol
+
+ var symbolMatching: SymbolMatching {
+ symbol
+ }
+}
+
+private struct MockRecordReader: RecordReaderProviding {
+ let occurrences: [MockSymbolOccurrence]
+
+ func forEachOccurrence(_ callback: (SymbolOccurrenceProviding) -> Void) {
+ occurrences.forEach { callback($0) }
+ }
+}
+
+private struct MockUnitDependency: UnitDependencyProviding {
+ let kind: DependencyKind
+ let name: String
+ let filePath: String
+}
+
+private struct MockUnitReader: UnitReaderProviding {
+ let isSystem: Bool
+ let dependencies: [MockUnitDependency]
+
+ func forEachDependency(_ callback: (UnitDependencyProviding) -> Void) {
+ dependencies.forEach { callback($0) }
+ }
+}
+
+private struct MockIndexStore: IndexStoreProviding {
+ 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]
+ }
+}
diff --git a/Tests/SwiftFindRefs/IndexStore/RecordIndexTests.swift b/Tests/SwiftFindRefs/IndexStore/RecordIndexTests.swift
new file mode 100644
index 0000000..5c9fb1c
--- /dev/null
+++ b/Tests/SwiftFindRefs/IndexStore/RecordIndexTests.swift
@@ -0,0 +1,297 @@
+import Foundation
+import Testing
+import IndexStore
+@testable import SwiftFindRefs
+
+@Suite("RecordIndex Tests")
+struct RecordIndexTests {
+
+ // MARK: - Initialization Tests
+
+ @Test("test init with empty arrays creates empty index")
+ func test_init_WithEmptyArrays_createsEmptyIndex() {
+ // Given / When
+ let sut = makeSUT(recordNames: [], recordToSource: [:])
+
+ // Then
+ #expect(sut.recordNames.isEmpty)
+ }
+
+ @Test("test init with record names stores them")
+ func test_init_WithRecordNames_storesThem() {
+ // Given
+ let expectedNames = ["Record1", "Record2", "Record3"]
+
+ // When
+ let sut = makeSUT(recordNames: expectedNames, recordToSource: [:])
+
+ // Then
+ #expect(sut.recordNames == expectedNames)
+ }
+
+ // MARK: - sourcePath Tests
+
+ @Test("test sourcePath with mapped record returns source path")
+ func test_sourcePath_WithMappedRecord_returnsSourcePath() {
+ // Given
+ let recordName = "MyRecord"
+ let expectedPath = "/path/to/MyFile.swift"
+ let sut = makeSUT(
+ recordNames: [recordName],
+ recordToSource: [recordName: expectedPath]
+ )
+
+ // When
+ let result = sut.sourcePath(for: recordName)
+
+ // Then
+ #expect(result == expectedPath)
+ }
+
+ @Test("test sourcePath with unmapped record returns record name as fallback")
+ func test_sourcePath_WithUnmappedRecord_returnsRecordNameAsFallback() {
+ // Given
+ let recordName = "UnmappedRecord"
+ let sut = makeSUT(
+ recordNames: [recordName],
+ recordToSource: [:]
+ )
+
+ // When
+ let result = sut.sourcePath(for: recordName)
+
+ // Then
+ #expect(result == recordName)
+ }
+
+ @Test("test sourcePath with multiple records returns correct paths")
+ func test_sourcePath_WithMultipleRecords_returnsCorrectPaths() {
+ // Given
+ let record1 = "Record1"
+ let record2 = "Record2"
+ let record3 = "Record3"
+ let path1 = "/path/to/File1.swift"
+ let path2 = "/path/to/File2.swift"
+ let sut = makeSUT(
+ recordNames: [record1, record2, record3],
+ recordToSource: [
+ record1: path1,
+ record2: path2
+ // record3 intentionally unmapped
+ ]
+ )
+
+ // When / Then
+ #expect(sut.sourcePath(for: record1) == path1)
+ #expect(sut.sourcePath(for: record2) == path2)
+ #expect(sut.sourcePath(for: record3) == record3) // fallback to record name
+ }
+
+ @Test("test sourcePath with nonexistent record returns the record name")
+ func test_sourcePath_WithNonexistentRecord_returnsRecordName() {
+ // Given
+ let sut = makeSUT(
+ recordNames: ["ExistingRecord"],
+ recordToSource: ["ExistingRecord": "/path/to/file.swift"]
+ )
+ let nonexistentRecord = "NonexistentRecord"
+
+ // When
+ let result = sut.sourcePath(for: nonexistentRecord)
+
+ // Then
+ #expect(result == nonexistentRecord)
+ }
+
+ // MARK: - build Tests
+
+ @Test("test build with empty store returns empty index")
+ func test_build_WithEmptyStore_returnsEmptyIndex() {
+ // Given
+ let store = MockIndexStore(units: [])
+
+ // When
+ let sut = RecordIndex.build(from: store)
+
+ // Then
+ #expect(sut.recordNames.isEmpty)
+ }
+
+ @Test("test build with system units skips them")
+ func test_build_WithSystemUnits_skipsThem() {
+ // Given
+ let systemUnit = MockUnitReader(
+ isSystem: true,
+ dependencies: [
+ MockUnitDependency(kind: .record, name: "SystemRecord", filePath: "/system/path.swift")
+ ]
+ )
+ let store = MockIndexStore(units: [systemUnit])
+
+ // When
+ let sut = RecordIndex.build(from: store)
+
+ // Then
+ #expect(sut.recordNames.isEmpty)
+ }
+
+ @Test("test build with non-system unit collects record dependencies")
+ func test_build_WithNonSystemUnit_collectsRecordDependencies() {
+ // Given
+ let expectedRecordName = "MyRecord"
+ let expectedFilePath = "/path/to/MyFile.swift"
+ let unit = MockUnitReader(
+ isSystem: false,
+ dependencies: [
+ MockUnitDependency(kind: .record, name: expectedRecordName, filePath: expectedFilePath)
+ ]
+ )
+ let store = MockIndexStore(units: [unit])
+
+ // When
+ let sut = RecordIndex.build(from: store)
+
+ // Then
+ #expect(sut.recordNames.contains(expectedRecordName))
+ #expect(sut.sourcePath(for: expectedRecordName) == expectedFilePath)
+ }
+
+ @Test("test build ignores non-record dependencies")
+ func test_build_WithNonRecordDependencies_ignoresThem() {
+ // Given
+ let unit = MockUnitReader(
+ isSystem: false,
+ dependencies: [
+ MockUnitDependency(kind: .unit, name: "UnitDep", filePath: "/unit/path.swift"),
+ MockUnitDependency(kind: .file, name: "FileDep", filePath: "/file/path.swift"),
+ MockUnitDependency(kind: .record, name: "RecordDep", filePath: "/record/path.swift")
+ ]
+ )
+ let store = MockIndexStore(units: [unit])
+
+ // When
+ let sut = RecordIndex.build(from: store)
+
+ // Then
+ #expect(sut.recordNames.count == 1)
+ #expect(sut.recordNames.contains("RecordDep"))
+ }
+
+ @Test("test build with empty file path uses record name as fallback")
+ func test_build_WithEmptyFilePath_usesRecordNameAsFallback() {
+ // Given
+ let recordName = "RecordWithEmptyPath"
+ let unit = MockUnitReader(
+ isSystem: false,
+ dependencies: [
+ MockUnitDependency(kind: .record, name: recordName, filePath: "")
+ ]
+ )
+ let store = MockIndexStore(units: [unit])
+
+ // When
+ let sut = RecordIndex.build(from: store)
+
+ // Then
+ #expect(sut.recordNames.contains(recordName))
+ #expect(sut.sourcePath(for: recordName) == recordName)
+ }
+
+ @Test("test build with duplicate records keeps first file path")
+ func test_build_WithDuplicateRecords_keepsFirstFilePath() {
+ // Given
+ let recordName = "DuplicateRecord"
+ let firstPath = "/first/path.swift"
+ let secondPath = "/second/path.swift"
+ let unit1 = MockUnitReader(
+ isSystem: false,
+ dependencies: [
+ MockUnitDependency(kind: .record, name: recordName, filePath: firstPath)
+ ]
+ )
+ let unit2 = MockUnitReader(
+ isSystem: false,
+ dependencies: [
+ MockUnitDependency(kind: .record, name: recordName, filePath: secondPath)
+ ]
+ )
+ let store = MockIndexStore(units: [unit1, unit2])
+
+ // When
+ let sut = RecordIndex.build(from: store)
+
+ // Then
+ #expect(sut.sourcePath(for: recordName) == firstPath)
+ }
+
+ @Test("test build with multiple units collects all records")
+ func test_build_WithMultipleUnits_collectsAllRecords() {
+ // Given
+ let unit1 = MockUnitReader(
+ isSystem: false,
+ dependencies: [
+ MockUnitDependency(kind: .record, name: "Record1", filePath: "/path1.swift")
+ ]
+ )
+ let unit2 = MockUnitReader(
+ isSystem: false,
+ dependencies: [
+ MockUnitDependency(kind: .record, name: "Record2", filePath: "/path2.swift")
+ ]
+ )
+ let systemUnit = MockUnitReader(
+ isSystem: true,
+ dependencies: [
+ MockUnitDependency(kind: .record, name: "SystemRecord", filePath: "/system.swift")
+ ]
+ )
+ let store = MockIndexStore(units: [unit1, systemUnit, unit2])
+
+ // When
+ let sut = RecordIndex.build(from: store)
+
+ // Then
+ #expect(sut.recordNames.count == 2)
+ #expect(sut.recordNames.contains("Record1"))
+ #expect(sut.recordNames.contains("Record2"))
+ #expect(!sut.recordNames.contains("SystemRecord"))
+ }
+
+ // MARK: - Helpers
+
+ private func makeSUT(
+ recordNames: [String],
+ recordToSource: [String: String]
+ ) -> RecordIndex {
+ RecordIndex(recordNames: recordNames, recordToSource: recordToSource)
+ }
+}
+
+// MARK: - Test Doubles
+
+private struct MockUnitDependency: UnitDependencyProviding {
+ let kind: DependencyKind
+ let name: String
+ let filePath: String
+}
+
+private struct MockUnitReader: UnitReaderProviding {
+ let isSystem: Bool
+ let dependencies: [MockUnitDependency]
+
+ func forEachDependency(_ callback: (UnitDependencyProviding) -> Void) {
+ dependencies.forEach { callback($0) }
+ }
+}
+
+private struct MockIndexStore: IndexStoreProviding {
+ let units: [MockUnitReader]
+
+ func forEachUnit(_ callback: (UnitReaderProviding) -> Void) {
+ units.forEach { callback($0) }
+ }
+
+ func recordReader(for recordName: String) throws -> RecordReaderProviding? {
+ nil // Not needed for RecordIndex tests
+ }
+}
diff --git a/Tests/SwiftFindRefs/IndexStore/SymbolQueryTests.swift b/Tests/SwiftFindRefs/IndexStore/SymbolQueryTests.swift
new file mode 100644
index 0000000..e2fd08a
--- /dev/null
+++ b/Tests/SwiftFindRefs/IndexStore/SymbolQueryTests.swift
@@ -0,0 +1,183 @@
+import Foundation
+import Testing
+import IndexStore
+@testable import SwiftFindRefs
+
+@Suite("SymbolQuery Tests")
+struct SymbolQueryTests {
+
+ // MARK: - Initialization Tests
+
+ @Test("test init with name only sets name and nil kind")
+ func test_init_WithNameOnly_setsNameAndNilKind() {
+ // Given
+ let symbolName = "MyClass"
+
+ // When
+ let sut = makeSUT(name: symbolName, kindString: nil)
+
+ // Then
+ #expect(sut.name == symbolName)
+ #expect(sut.kind == nil)
+ }
+
+ @Test("test init with valid kind string sets parsed kind")
+ func test_init_WithValidKindString_setsParsedKind() {
+ // Given
+ let symbolName = "MyClass"
+ let kindString = "class"
+
+ // When
+ let sut = makeSUT(name: symbolName, kindString: kindString)
+
+ // Then
+ #expect(sut.name == symbolName)
+ #expect(sut.kind == .class)
+ }
+
+ @Test("test init with invalid kind string sets nil kind")
+ func test_init_WithInvalidKindString_setsNilKind() {
+ // Given
+ let symbolName = "MySymbol"
+ let kindString = "unknownType"
+
+ // When
+ let sut = makeSUT(name: symbolName, kindString: kindString)
+
+ // Then
+ #expect(sut.name == symbolName)
+ #expect(sut.kind == nil)
+ }
+
+ @Test("test init with uppercase kind string parses correctly")
+ func test_init_WithUppercaseKindString_parsesCaseInsensitively() {
+ // Given
+ let symbolName = "MyStruct"
+ let kindString = "STRUCT"
+
+ // When
+ let sut = makeSUT(name: symbolName, kindString: kindString)
+
+ // Then
+ #expect(sut.kind == .struct)
+ }
+
+ @Test("test init with mixed case kind string parses correctly")
+ func test_init_WithMixedCaseKindString_parsesCaseInsensitively() {
+ // Given
+ let symbolName = "MyProtocol"
+ let kindString = "ProToCoL"
+
+ // When
+ let sut = makeSUT(name: symbolName, kindString: kindString)
+
+ // Then
+ #expect(sut.kind == .protocol)
+ }
+
+ // MARK: - Kind Parsing Tests
+
+ @Test("test init parses all supported symbol kinds", arguments: supportedKindMappings())
+ func test_init_WithSupportedKind_parsesCorrectly(mapping: (String, SymbolKind)) {
+ // Given
+ let (kindString, expectedKind) = mapping
+
+ // When
+ let sut = makeSUT(name: "Symbol", kindString: kindString)
+
+ // Then
+ #expect(sut.kind == expectedKind)
+ }
+
+ // MARK: - Matches Tests (using MockSymbol)
+
+ @Test("test matches with matching name and nil kind returns true")
+ func test_matches_WithMatchingNameAndNilKind_returnsTrue() {
+ // Given
+ let sut = makeSUT(name: "Selection", kindString: nil)
+ let symbol = MockSymbol(name: "Selection", kind: .class)
+
+ // When
+ let result = sut.matches(symbol)
+
+ // Then
+ #expect(result == true)
+ }
+
+ @Test("test matches with different name returns false")
+ func test_matches_WithDifferentName_returnsFalse() {
+ // Given
+ let sut = makeSUT(name: "Selection", kindString: nil)
+ let symbol = MockSymbol(name: "OtherClass", kind: .class)
+
+ // When
+ let result = sut.matches(symbol)
+
+ // Then
+ #expect(result == false)
+ }
+
+ @Test("test matches with matching name and kind returns true")
+ func test_matches_WithMatchingNameAndKind_returnsTrue() {
+ // Given
+ let sut = makeSUT(name: "Selection", kindString: "class")
+ let symbol = MockSymbol(name: "Selection", kind: .class)
+
+ // When
+ let result = sut.matches(symbol)
+
+ // Then
+ #expect(result == true)
+ }
+
+ @Test("test matches with matching name but different kind returns false")
+ func test_matches_WithMatchingNameButDifferentKind_returnsFalse() {
+ // Given
+ let sut = makeSUT(name: "Selection", kindString: "class")
+ let symbol = MockSymbol(name: "Selection", kind: .struct)
+
+ // When
+ let result = sut.matches(symbol)
+
+ // Then
+ #expect(result == false)
+ }
+
+ // MARK: - Helpers
+
+ private func makeSUT(name: String, kindString: String?) -> SymbolQuery {
+ SymbolQuery(name: name, kindString: kindString)
+ }
+
+ private static func supportedKindMappings() -> [(String, SymbolKind)] {
+ [
+ ("class", .class),
+ ("struct", .struct),
+ ("enum", .enum),
+ ("protocol", .protocol),
+ ("function", .function),
+ ("variable", .variable),
+ ("typealias", .typealias),
+ ("instancemethod", .instanceMethod),
+ ("staticmethod", .staticMethod),
+ ("classmethod", .classMethod),
+ ("instanceproperty", .instanceProperty),
+ ("staticproperty", .staticProperty),
+ ("classproperty", .classProperty),
+ ("constructor", .constructor),
+ ("destructor", .destructor),
+ ("field", .field),
+ ("enumconstant", .enumConstant),
+ ("parameter", .parameter),
+ ("module", .module),
+ ("extension", .extension),
+ ]
+ }
+}
+
+// MARK: - Test Doubles
+
+private struct MockSymbol: SymbolMatching {
+ let name: String
+ let kind: SymbolKind
+}
diff --git a/swiftfindrefs.rb b/swiftfindrefs.rb
index 70d42e3..c73c2fe 100644
--- a/swiftfindrefs.rb
+++ b/swiftfindrefs.rb
@@ -1,8 +1,8 @@
class Swiftfindrefs < Formula
desc "SwiftFindRefs is a macOS Swift CLI that resolves a project’s DerivedData, reads Xcode’s IndexStore, and reports every file referencing a chosen symbol, with optional verbose tracing for diagnostics."
homepage "https://github.com/michaelversus/SwiftFindRefs"
- url "https://github.com/michaelversus/SwiftFindRefs.git", tag: "0.1.5"
- version "0.1.5"
+ url "https://github.com/michaelversus/SwiftFindRefs.git", tag: "0.2.1"
+ version "0.2.1"
depends_on "xcode": [:build]