From 47ca615ae590ea42e2fbd1ad81254563c52b2d82 Mon Sep 17 00:00:00 2001 From: Michalis Karagiorgos Date: Wed, 14 Jan 2026 01:19:07 +0200 Subject: [PATCH 1/5] improve architecture --- .../IndexStore/IndexStoreFinder.swift | 102 ++++++------------ .../IndexStore/RecordIndex.swift | 33 ++++++ .../IndexStore/SymbolKind+Parsing.swift | 29 +++++ .../IndexStore/SymbolQuery.swift | 18 ++++ 4 files changed, 113 insertions(+), 69 deletions(-) create mode 100644 Sources/SwiftFindRefs/IndexStore/RecordIndex.swift create mode 100644 Sources/SwiftFindRefs/IndexStore/SymbolKind+Parsing.swift create mode 100644 Sources/SwiftFindRefs/IndexStore/SymbolQuery.swift diff --git a/Sources/SwiftFindRefs/IndexStore/IndexStoreFinder.swift b/Sources/SwiftFindRefs/IndexStore/IndexStoreFinder.swift index 2f7e6fe..97a81f5 100644 --- a/Sources/SwiftFindRefs/IndexStore/IndexStoreFinder.swift +++ b/Sources/SwiftFindRefs/IndexStore/IndexStoreFinder.swift @@ -6,86 +6,50 @@ struct IndexStoreFinder { func fileReferences(of symbolName: String, symbolType: String?) throws -> [String] { let store = try IndexStore(path: indexStorePath) + let query = SymbolQuery(name: symbolName, kindString: symbolType) + let index = RecordIndex.build(from: store) - // Pre-compute SymbolKind enum to avoid string comparison in hot loop - let expectedSymbolKind: SymbolKind? = symbolType.flatMap { parseSymbolKind($0) } - - // Collect all record dependencies with their source paths in a single pass - var recordToSource: [String: String] = [:] - var allRecordNames = Set() - - 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 - } - } - } - - // Convert to array for parallel processing - let recordNames = Array(allRecordNames) + return searchRecordsInParallel(store: store, index: index, query: query) + } + + private func searchRecordsInParallel( + store: IndexStore, + index: RecordIndex, + query: SymbolQuery + ) -> [String] { let lock = NSLock() var referencedFiles = Set() - - // 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 - } - - 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 + + DispatchQueue.concurrentPerform(iterations: index.recordNames.count) { i in + let recordName = index.recordNames[i] + + if recordContainsSymbol(store: store, recordName: recordName, query: query) { + let filename = index.sourcePath(for: recordName) lock.lock() referencedFiles.insert(filename) lock.unlock() } } - + return referencedFiles.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: IndexStore, + recordName: String, + query: SymbolQuery + ) -> Bool { + guard let recordReader = try? RecordReader(indexStore: store, recordName: recordName) else { + return false + } + + var found = false + recordReader.forEach { (occurrence: SymbolOccurrence) in + guard !found else { return } + if query.matches(occurrence.symbol) { + found = true + } } + return found } } diff --git a/Sources/SwiftFindRefs/IndexStore/RecordIndex.swift b/Sources/SwiftFindRefs/IndexStore/RecordIndex.swift new file mode 100644 index 0000000..0c9f609 --- /dev/null +++ b/Sources/SwiftFindRefs/IndexStore/RecordIndex.swift @@ -0,0 +1,33 @@ +import IndexStore + +/// Maps record names to their source file paths +struct RecordIndex { + let recordNames: [String] + private let recordToSource: [String: String] + + func sourcePath(for recordName: String) -> String { + recordToSource[recordName] ?? recordName + } + + static func build(from store: IndexStore) -> RecordIndex { + var recordToSource: [String: String] = [:] + var allRecordNames = Set() + + for unitReader in store.units where !unitReader.isSystem { + unitReader.forEach { 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/SymbolQuery.swift b/Sources/SwiftFindRefs/IndexStore/SymbolQuery.swift new file mode 100644 index 0000000..106b6ec --- /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: Symbol) -> Bool { + guard symbol.name == name else { return false } + if let kind, symbol.kind != kind { return false } + return true + } +} From 61a7363b4bcc4326f5ca7834d841ffda8e831380 Mon Sep 17 00:00:00 2001 From: Michalis Karagiorgos Date: Wed, 14 Jan 2026 01:27:24 +0200 Subject: [PATCH 2/5] write tests for SymbolQuery --- .../IndexStore/SymbolMatching.swift | 9 + .../IndexStore/SymbolQuery.swift | 2 +- .../IndexStore/SymbolQueryTests.swift | 183 ++++++++++++++++++ 3 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 Sources/SwiftFindRefs/IndexStore/SymbolMatching.swift create mode 100644 Tests/SwiftFindRefs/IndexStore/SymbolQueryTests.swift 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 index 106b6ec..a8e5387 100644 --- a/Sources/SwiftFindRefs/IndexStore/SymbolQuery.swift +++ b/Sources/SwiftFindRefs/IndexStore/SymbolQuery.swift @@ -10,7 +10,7 @@ struct SymbolQuery { self.kind = kindString.flatMap { SymbolKind(parsing: $0) } } - func matches(_ symbol: Symbol) -> Bool { + 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/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 +} From 732eded9ff8ce8bafa1324b4553d011d88db2258 Mon Sep 17 00:00:00 2001 From: Michalis Karagiorgos Date: Wed, 14 Jan 2026 01:40:12 +0200 Subject: [PATCH 3/5] add more tests for RecordIndex --- .../IndexStore/IndexStore+Providing.swift | 21 ++ .../IndexStore/IndexStoreProviding.swift | 19 ++ .../IndexStore/RecordIndex.swift | 14 +- .../IndexStore/RecordIndexTests.swift | 293 ++++++++++++++++++ 4 files changed, 344 insertions(+), 3 deletions(-) create mode 100644 Sources/SwiftFindRefs/IndexStore/IndexStore+Providing.swift create mode 100644 Sources/SwiftFindRefs/IndexStore/IndexStoreProviding.swift create mode 100644 Tests/SwiftFindRefs/IndexStore/RecordIndexTests.swift diff --git a/Sources/SwiftFindRefs/IndexStore/IndexStore+Providing.swift b/Sources/SwiftFindRefs/IndexStore/IndexStore+Providing.swift new file mode 100644 index 0000000..c650fde --- /dev/null +++ b/Sources/SwiftFindRefs/IndexStore/IndexStore+Providing.swift @@ -0,0 +1,21 @@ +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) + } + } +} diff --git a/Sources/SwiftFindRefs/IndexStore/IndexStoreProviding.swift b/Sources/SwiftFindRefs/IndexStore/IndexStoreProviding.swift new file mode 100644 index 0000000..d4e7aeb --- /dev/null +++ b/Sources/SwiftFindRefs/IndexStore/IndexStoreProviding.swift @@ -0,0 +1,19 @@ +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) +} diff --git a/Sources/SwiftFindRefs/IndexStore/RecordIndex.swift b/Sources/SwiftFindRefs/IndexStore/RecordIndex.swift index 0c9f609..0c6a2d3 100644 --- a/Sources/SwiftFindRefs/IndexStore/RecordIndex.swift +++ b/Sources/SwiftFindRefs/IndexStore/RecordIndex.swift @@ -5,16 +5,24 @@ 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: IndexStore) -> RecordIndex { + static func build(from store: some IndexStoreProviding) -> RecordIndex { var recordToSource: [String: String] = [:] var allRecordNames = Set() - for unitReader in store.units where !unitReader.isSystem { - unitReader.forEach { dependency in + 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) diff --git a/Tests/SwiftFindRefs/IndexStore/RecordIndexTests.swift b/Tests/SwiftFindRefs/IndexStore/RecordIndexTests.swift new file mode 100644 index 0000000..092eb31 --- /dev/null +++ b/Tests/SwiftFindRefs/IndexStore/RecordIndexTests.swift @@ -0,0 +1,293 @@ +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) } + } +} From d8b2632998ce223c51c74fd3dc327151a5ab9f23 Mon Sep 17 00:00:00 2001 From: Michalis Karagiorgos Date: Wed, 14 Jan 2026 02:01:07 +0200 Subject: [PATCH 4/5] Improve Architecture and add more tests --- README.md | 2 +- .../IndexStore/IndexStore+Providing.swift | 18 + .../IndexStore/IndexStoreFinder.swift | 59 +++- .../IndexStore/IndexStoreProviding.swift | 11 + .../IndexStore/IndexStoreFinderTests.swift | 324 +++++++++++++++++- .../IndexStore/RecordIndexTests.swift | 4 + 6 files changed, 397 insertions(+), 21 deletions(-) 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 index c650fde..94205d5 100644 --- a/Sources/SwiftFindRefs/IndexStore/IndexStore+Providing.swift +++ b/Sources/SwiftFindRefs/IndexStore/IndexStore+Providing.swift @@ -18,4 +18,22 @@ extension IndexStore: IndexStoreProviding { 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 97a81f5..e08a101 100644 --- a/Sources/SwiftFindRefs/IndexStore/IndexStoreFinder.swift +++ b/Sources/SwiftFindRefs/IndexStore/IndexStoreFinder.swift @@ -1,55 +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) + return try fileReferences(of: symbolName, symbolType: symbolType, from: store) + } + + 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) - + return searchRecordsInParallel(store: store, index: index, query: query) } - + private func searchRecordsInParallel( - store: IndexStore, + store: some IndexStoreProviding & Sendable, index: RecordIndex, query: SymbolQuery ) -> [String] { - let lock = NSLock() - var referencedFiles = Set() - + let referencedFiles = ThreadSafeSet() + DispatchQueue.concurrentPerform(iterations: index.recordNames.count) { i in let recordName = index.recordNames[i] - + if recordContainsSymbol(store: store, recordName: recordName, query: query) { let filename = index.sourcePath(for: recordName) - lock.lock() referencedFiles.insert(filename) - lock.unlock() } } - - return referencedFiles.sorted() + + return referencedFiles.values().sorted() } - + private func recordContainsSymbol( - store: IndexStore, + store: some IndexStoreProviding, recordName: String, query: SymbolQuery ) -> Bool { - guard let recordReader = try? RecordReader(indexStore: store, recordName: recordName) else { + guard let recordReader = try? store.recordReader(for: recordName) else { return false } - + var found = false - recordReader.forEach { (occurrence: SymbolOccurrence) in + recordReader.forEachOccurrence { occurrence in guard !found else { return } - if query.matches(occurrence.symbol) { + 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 index d4e7aeb..ec184c1 100644 --- a/Sources/SwiftFindRefs/IndexStore/IndexStoreProviding.swift +++ b/Sources/SwiftFindRefs/IndexStore/IndexStoreProviding.swift @@ -16,4 +16,15 @@ protocol UnitReaderProviding { /// 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/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 index 092eb31..5c9fb1c 100644 --- a/Tests/SwiftFindRefs/IndexStore/RecordIndexTests.swift +++ b/Tests/SwiftFindRefs/IndexStore/RecordIndexTests.swift @@ -290,4 +290,8 @@ private struct MockIndexStore: IndexStoreProviding { func forEachUnit(_ callback: (UnitReaderProviding) -> Void) { units.forEach { callback($0) } } + + func recordReader(for recordName: String) throws -> RecordReaderProviding? { + nil // Not needed for RecordIndex tests + } } From 242a71607b7ceea2bef1976cb8791808d0268c84 Mon Sep 17 00:00:00 2001 From: Michalis Karagiorgos Date: Wed, 14 Jan 2026 02:04:27 +0200 Subject: [PATCH 5/5] bump up version --- swiftfindrefs.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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]