Skip to content

Commit b30b972

Browse files
authored
Merge pull request #1302 from ahoppen/watch-file-changes-index
Update index as files are modified on disk
2 parents 0e0f6d3 + 546bb32 commit b30b972

15 files changed

+385
-45
lines changed

Documentation/Files_To_Reindex.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Which files to re-index when a file is modified
2+
3+
## `.swift`
4+
5+
Obviously affects the file itself, which we do re-index.
6+
7+
If an internal declaration was changed, all files within the module might be also affected. If a public declaration was changed, all modules that depend on it might be affected. The effect can only really be in three ways:
8+
1. It might change overload resolution in another file, which is fairly unlikely
9+
2. A new declaration is introduced in this file that is already referenced by another file
10+
3. A declaration is removed in this file that was referenced from other files. In those cases the other files now have an invalid reference.
11+
12+
We decided to not re-index any files other than the file itself because naively re-indexing all files that might depend on the modified file requires too much processing power that will likely have no or very little effect on the index – we are trading accuracy for CPU time here.
13+
We mark the targets of the changed file as well as any dependent targets as out-of-date. The assumption is that most likely the user will go back to any of the affected files shortly afterwards and modify them again. When that happens, the affected file will get re-indexed and bring the index back up to date.
14+
15+
Alternatives would be:
16+
- We could we check the file’s interface hash and re-index other files based on whether it has changed. But at that point we are somewhat implementing a build system. And even if a new public method was introduced it’s very likely that the user hasn’t actually used it anywhere yet, which means that re-indexing all dependent modules would still be doing unnecessary work.
17+
- To cover case (2) from above, we could re-index only dependencies that previously indexed with errors. This is an alternative that hasn’t been fully explored.
18+
19+
## `.h`
20+
21+
Affects all files that include the header (including via other headers). For existing headers, we know which files import a header from the existing index. For new header files, we assume that it hasn’t been included in any file yet and so we don’t re-index any file. If it is, it’s likely that the user will modify the file that includes the new header file shortly after, which will re-index it.
22+
23+
## `.c` / `.cpp` / `.m`
24+
25+
This is the easy case since only the file itself is affected.
26+
27+
## Compiler settings (`compile_commands.json` / `Package.swift`)
28+
29+
Ideally, we would considered like a file change for all files whose compile commands have changed, if they changed in a meaningful way (ie. in a way that would also trigger re-compilation in an incremental build). Non-meaningful changes would be:
30+
- If compiler arguments, like include paths are shuffled around. We could have a really quick check for compiler arguments equality by comparing them unordered. Any really compiler argument change will most likely do more than rearranging the arguments.
31+
- The addition of a new Swift file to a target is equivalent to that file being modified and shouldn’t trigger a re-index of the entire target.
32+
33+
At the moment, unit files don’t include information about the compiler arguments with which they were created, so it’s impossible to know whether the compiler arguments have changed when a project is opened. Thus, for now, we don’t re-index any files on compiler settings changing.

Sources/SKCore/BuildSystem.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,8 @@ public protocol BuildSystem: AnyObject, Sendable {
167167
func fileHandlingCapability(for uri: DocumentURI) async -> FileHandlingCapability
168168

169169
/// Returns the list of source files in the project.
170+
///
171+
/// Header files should not be considered as source files because they cannot be compiled.
170172
func sourceFiles() async -> [SourceFileInfo]
171173

172174
/// Adds a callback that should be called when the value returned by `sourceFiles()` changes.

Sources/SKSupport/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ add_library(SKSupport STATIC
1515
Process+WaitUntilExitWithCancellation.swift
1616
Random.swift
1717
Result.swift
18+
Sequence+AsyncMap.swift
1819
SwitchableProcessResultExitStatus.swift
1920
ThreadSafeBox.swift
2021
WorkspaceType.swift

Sources/SourceKitLSP/Sequence+AsyncMap.swift renamed to Sources/SKSupport/Sequence+AsyncMap.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
extension Sequence {
1414
/// Just like `Sequence.map` but allows an `async` transform function.
15-
func asyncMap<T>(
15+
public func asyncMap<T>(
1616
@_inheritActorContext _ transform: @Sendable (Element) async throws -> T
1717
) async rethrows -> [T] {
1818
var result: [T] = []
@@ -26,7 +26,7 @@ extension Sequence {
2626
}
2727

2828
/// Just like `Sequence.compactMap` but allows an `async` transform function.
29-
func asyncCompactMap<T>(
29+
public func asyncCompactMap<T>(
3030
@_inheritActorContext _ transform: @Sendable (Element) async throws -> T?
3131
) async rethrows -> [T] {
3232
var result: [T] = []

Sources/SKSwiftPMWorkspace/SwiftPMBuildSystem.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,7 @@ extension SwiftPMBuildSystem: SKCore.BuildSystem {
387387
)
388388
}
389389

390-
public func defaultLanguage(for document: DocumentURI) async -> Language? {
390+
public func defaultLanguage(for document: DocumentURI) -> Language? {
391391
// TODO (indexing): Query The SwiftPM build system for the document's language.
392392
// https://github.com/apple/sourcekit-lsp/issues/1267
393393
return nil

Sources/SKTestSupport/Utils.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,12 @@ extension Language {
2525

2626
init?(fileExtension: String) {
2727
switch fileExtension {
28+
case "c": self = .c
29+
case "cpp": self = .cpp
2830
case "m": self = .objective_c
29-
default: self.init(rawValue: fileExtension)
31+
case "mm": self = .objective_cpp
32+
case "swift": self = .swift
33+
default: return nil
3034
}
3135
}
3236
}

Sources/SemanticIndex/CheckedIndex.swift

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,6 @@ public final class CheckedIndex {
104104
}
105105
}
106106

107-
public func symbolProvider(for sourceFilePath: String) -> SymbolProviderKind? {
108-
return index.symbolProvider(for: sourceFilePath)
109-
}
110-
111107
public func symbols(inFilePath path: String) -> [Symbol] {
112108
guard self.hasUpToDateUnit(for: URL(fileURLWithPath: path, isDirectory: false)) else {
113109
return []
@@ -120,6 +116,19 @@ public final class CheckedIndex {
120116
return index.unitTests(referencedByMainFiles: mainFilePaths).filter { checker.isUpToDate($0.location) }
121117
}
122118

119+
/// Returns all the files that (transitively) include the header file at the given path.
120+
///
121+
/// If `crossLanguage` is set to `true`, Swift files that import a header will through a module will also be reported.
122+
public func mainFilesContainingFile(uri: DocumentURI, crossLanguage: Bool = false) -> [DocumentURI] {
123+
return index.mainFilesContainingFile(path: uri.pseudoPath, crossLanguage: crossLanguage).compactMap {
124+
let url = URL(fileURLWithPath: $0)
125+
guard checker.indexHasUpToDateUnit(for: url, index: self.index) else {
126+
return nil
127+
}
128+
return DocumentURI(url)
129+
}
130+
}
131+
123132
/// Returns all unit test symbols in the index.
124133
public func unitTests() -> [SymbolOccurrence] {
125134
return index.unitTests().filter { checker.isUpToDate($0.location) }
@@ -139,11 +148,6 @@ public final class CheckedIndex {
139148
public func fileHasInMemoryModifications(_ url: URL) -> Bool {
140149
return checker.fileHasInMemoryModifications(url)
141150
}
142-
143-
/// Wait for IndexStoreDB to be updated based on new unit files written to disk.
144-
public func pollForUnitChangesAndWait() {
145-
self.index.pollForUnitChangesAndWait()
146-
}
147151
}
148152

149153
/// A wrapper around `IndexStoreDB` that allows the retrieval of a `CheckedIndex` with a specified check level or the
@@ -167,6 +171,15 @@ public struct UncheckedIndex: Sendable {
167171
public func checked(for checkLevel: IndexCheckLevel) -> CheckedIndex {
168172
return CheckedIndex(index: underlyingIndexStoreDB, checkLevel: checkLevel)
169173
}
174+
175+
public func symbolProvider(for sourceFilePath: String) -> SymbolProviderKind? {
176+
return underlyingIndexStoreDB.symbolProvider(for: sourceFilePath)
177+
}
178+
179+
/// Wait for IndexStoreDB to be updated based on new unit files written to disk.
180+
public func pollForUnitChangesAndWait() {
181+
self.underlyingIndexStoreDB.pollForUnitChangesAndWait()
182+
}
170183
}
171184

172185
/// Helper class to check if symbols from the index are up-to-date or if the source file has been modified after it was

Sources/SemanticIndex/SemanticIndexManager.swift

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ private enum FileIndexStatus {
4141
public final actor SemanticIndexManager {
4242
/// The underlying index. This is used to check if the index of a file is already up-to-date, in which case it doesn't
4343
/// need to be indexed again.
44-
private let index: CheckedIndex
44+
private let index: UncheckedIndex
4545

4646
/// The build system manager that is used to get compiler arguments for a file.
4747
private let buildSystemManager: BuildSystemManager
@@ -101,14 +101,17 @@ public final actor SemanticIndexManager {
101101
indexTasksWereScheduled: @escaping @Sendable (Int) -> Void,
102102
indexTaskDidFinish: @escaping @Sendable () -> Void
103103
) {
104-
self.index = index.checked(for: .modifiedFiles)
104+
self.index = index
105105
self.buildSystemManager = buildSystemManager
106106
self.indexTaskScheduler = indexTaskScheduler
107107
self.indexTasksWereScheduled = indexTasksWereScheduled
108108
self.indexTaskDidFinish = indexTaskDidFinish
109109
}
110110

111-
/// Schedules a task to index all files in `files` that don't already have an up-to-date index.
111+
/// Schedules a task to index `files`. Files that are known to be up-to-date based on `indexStatus` will
112+
/// not be re-indexed. The method will re-index files even if they have a unit with a timestamp that matches the
113+
/// source file's mtime. This allows re-indexing eg. after compiler arguments or dependencies have changed.
114+
///
112115
/// Returns immediately after scheduling that task.
113116
///
114117
/// Indexing is being performed with a low priority.
@@ -117,11 +120,22 @@ public final actor SemanticIndexManager {
117120
}
118121

119122
/// Regenerate the build graph (also resolving package dependencies) and then index all the source files known to the
120-
/// build system.
123+
/// build system that don't currently have a unit with a timestamp that matches the mtime of the file.
124+
///
125+
/// This method is intended to initially update the index of a project after it is opened.
121126
public func scheduleBuildGraphGenerationAndBackgroundIndexAllFiles() async {
122127
generateBuildGraphTask = Task(priority: .low) {
123128
await orLog("Generating build graph") { try await self.buildSystemManager.generateBuildGraph() }
124-
await scheduleBackgroundIndex(files: await self.buildSystemManager.sourceFiles().map(\.uri))
129+
let index = index.checked(for: .modifiedFiles)
130+
let filesToIndex = await self.buildSystemManager.sourceFiles().map(\.uri)
131+
.filter { uri in
132+
guard let url = uri.fileURL else {
133+
// The URI is not a file, so there's nothing we can index.
134+
return false
135+
}
136+
return !index.hasUpToDateUnit(for: url)
137+
}
138+
await scheduleBackgroundIndex(files: filesToIndex)
125139
generateBuildGraphTask = nil
126140
}
127141
}
@@ -170,6 +184,34 @@ public final actor SemanticIndexManager {
170184
logger.debug("Done waiting for up-to-date index")
171185
}
172186

187+
public func filesDidChange(_ events: [FileEvent]) async {
188+
let filesToReIndex = await filesToReIndex(changedFiles: events.map(\.uri))
189+
190+
// Reset the index status for these files so they get re-indexed by `index(files:priority:)`
191+
for uri in filesToReIndex {
192+
indexStatus[uri] = nil
193+
}
194+
await scheduleBackgroundIndex(files: filesToReIndex)
195+
}
196+
197+
/// Returns the files that should be re-indexed if the given files have been modified.
198+
///
199+
/// - SeeAlso: The `Documentation/Files_To_Reindex.md` file.
200+
private func filesToReIndex(changedFiles: [DocumentURI]) async -> Set<DocumentURI> {
201+
let sourceFiles = Set(await buildSystemManager.sourceFiles().map(\.uri))
202+
let filesToReIndex = await changedFiles.asyncMap { (uri) -> [DocumentURI] in
203+
if sourceFiles.contains(uri) {
204+
// If this is a source file, re-index it
205+
return [uri]
206+
}
207+
// Otherwise, see if it is a header file. If so, re-index all the files that import it.
208+
// We don't want to re-index `.swift` files that depend on a header file, similar to how we don't re-index a Swift
209+
// module if one of its dependent modules has changed.
210+
return index.checked(for: .deletedFiles).mainFilesContainingFile(uri: uri, crossLanguage: false)
211+
}.flatMap { $0 }
212+
return Set(filesToReIndex)
213+
}
214+
173215
// MARK: - Helper functions
174216

175217
/// Prepare the given targets for indexing
@@ -189,8 +231,7 @@ public final actor SemanticIndexManager {
189231
let taskDescription = AnyIndexTaskDescription(
190232
UpdateIndexStoreTaskDescription(
191233
filesToIndex: Set(files),
192-
buildSystemManager: self.buildSystemManager,
193-
index: self.index.unchecked
234+
buildSystemManager: self.buildSystemManager
194235
)
195236
)
196237
let updateIndexStoreTask = await self.indexTaskScheduler.schedule(priority: priority, taskDescription) { newState in

Sources/SemanticIndex/UpdateIndexStoreTaskDescription.swift

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,6 @@ public struct UpdateIndexStoreTaskDescription: IndexTaskDescription {
3535
/// The build system manager that is used to get the toolchain and build settings for the files to index.
3636
private let buildSystemManager: BuildSystemManager
3737

38-
/// A reference to the underlying index store. Used to check if the index is already up-to-date for a file, in which
39-
/// case we don't need to index it again.
40-
private let index: UncheckedIndex
41-
4238
/// The task is idempotent because indexing the same file twice produces the same result as indexing it once.
4339
public var isIdempotent: Bool { true }
4440

@@ -54,12 +50,10 @@ public struct UpdateIndexStoreTaskDescription: IndexTaskDescription {
5450

5551
init(
5652
filesToIndex: Set<DocumentURI>,
57-
buildSystemManager: BuildSystemManager,
58-
index: UncheckedIndex
53+
buildSystemManager: BuildSystemManager
5954
) {
6055
self.filesToIndex = filesToIndex
6156
self.buildSystemManager = buildSystemManager
62-
self.index = index
6357
}
6458

6559
public func execute() async {
@@ -111,15 +105,6 @@ public struct UpdateIndexStoreTaskDescription: IndexTaskDescription {
111105
}
112106

113107
private func updateIndexStoreForSingleFile(_ uri: DocumentURI) async {
114-
guard let url = uri.fileURL else {
115-
// The URI is not a file, so there's nothing we can index.
116-
return
117-
}
118-
guard !index.checked(for: .modifiedFiles).hasUpToDateUnit(for: url) else {
119-
// We consider a file's index up-to-date if we have any up-to-date unit. Changing build settings does not
120-
// invalidate the up-to-date status of the index.
121-
return
122-
}
123108
guard let language = await buildSystemManager.defaultLanguage(for: uri) else {
124109
logger.error("Not indexing \(uri.forLogging) because its language could not be determined")
125110
return

Sources/SourceKitLSP/CMakeLists.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ add_library(SourceKitLSP STATIC
88
LanguageService.swift
99
Rename.swift
1010
ResponseError+Init.swift
11-
Sequence+AsyncMap.swift
1211
SourceKitIndexDelegate.swift
1312
SourceKitLSPCommandMetadata.swift
1413
SourceKitLSPServer.swift

Sources/SourceKitLSP/Rename.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -500,7 +500,7 @@ extension SourceKitLSPServer {
500500
) async -> (swiftLanguageService: SwiftLanguageService, snapshot: DocumentSnapshot, location: SymbolLocation)? {
501501
var reference: SymbolOccurrence? = nil
502502
index.forEachSymbolOccurrence(byUSR: usr, roles: renameRoles) {
503-
if index.symbolProvider(for: $0.location.path) == .swift {
503+
if index.unchecked.symbolProvider(for: $0.location.path) == .swift {
504504
reference = $0
505505
// We have found a reference from Swift. Stop iteration.
506506
return false
@@ -631,7 +631,7 @@ extension SourceKitLSPServer {
631631
// If we terminate early by returning `false` from the closure, `forEachSymbolOccurrence` returns `true`,
632632
// indicating that we have found a reference from clang.
633633
let hasReferenceFromClang = !index.forEachSymbolOccurrence(byUSR: usr, roles: renameRoles) {
634-
return index.symbolProvider(for: $0.location.path) != .clang
634+
return index.unchecked.symbolProvider(for: $0.location.path) != .clang
635635
}
636636
let clangName: String?
637637
if hasReferenceFromClang {
@@ -745,7 +745,7 @@ extension SourceKitLSPServer {
745745
return cachedValue
746746
}
747747
let serverType = LanguageServerType(
748-
symbolProvider: index.checked(for: .deletedFiles).symbolProvider(for: url.path)
748+
symbolProvider: index.symbolProvider(for: url.path)
749749
)
750750
languageServerTypesCache[url] = serverType
751751
return serverType

Sources/SourceKitLSP/SourceKitLSPServer.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -847,6 +847,7 @@ public actor SourceKitLSPServer {
847847
guard let toolchain = await workspace.buildSystemManager.toolchain(for: uri, language),
848848
let service = await languageService(for: toolchain, language, in: workspace)
849849
else {
850+
logger.error("Failed to create language service for \(uri)")
850851
return nil
851852
}
852853

@@ -2486,7 +2487,7 @@ extension SourceKitLSPServer {
24862487
func pollIndex(_ req: PollIndexRequest) async throws -> VoidResponse {
24872488
for workspace in workspaces {
24882489
await workspace.semanticIndexManager?.waitForUpToDateIndex()
2489-
workspace.index(checkedFor: .deletedFiles)?.pollForUnitChangesAndWait()
2490+
workspace.uncheckedIndex?.pollForUnitChangesAndWait()
24902491
}
24912492
return VoidResponse()
24922493
}

Sources/SourceKitLSP/Workspace.swift

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,11 @@ public final class Workspace: Sendable {
6363
/// The source code index, if available.
6464
///
6565
/// Usually a checked index (retrieved using `index(checkedFor:)`) should be used instead of the unchecked index.
66-
private let uncheckedIndex: ThreadSafeBox<UncheckedIndex?>
66+
private let _uncheckedIndex: ThreadSafeBox<UncheckedIndex?>
67+
68+
public var uncheckedIndex: UncheckedIndex? {
69+
return _uncheckedIndex.value
70+
}
6771

6872
/// The index that syntactically scans the workspace for tests.
6973
let syntacticTestIndex = SyntacticTestIndex()
@@ -97,7 +101,7 @@ public final class Workspace: Sendable {
97101
self.buildSetup = options.buildSetup
98102
self.rootUri = rootUri
99103
self.capabilityRegistry = capabilityRegistry
100-
self.uncheckedIndex = ThreadSafeBox(initialValue: uncheckedIndex)
104+
self._uncheckedIndex = ThreadSafeBox(initialValue: uncheckedIndex)
101105
self.buildSystemManager = await BuildSystemManager(
102106
buildSystem: underlyingBuildSystem,
103107
fallbackBuildSystem: FallbackBuildSystem(buildSetup: buildSetup),
@@ -261,20 +265,21 @@ public final class Workspace: Sendable {
261265
/// Returns a `CheckedIndex` that verifies that all the returned entries are up-to-date with the given
262266
/// `IndexCheckLevel`.
263267
func index(checkedFor checkLevel: IndexCheckLevel) -> CheckedIndex? {
264-
return uncheckedIndex.value?.checked(for: checkLevel)
268+
return _uncheckedIndex.value?.checked(for: checkLevel)
265269
}
266270

267271
/// Write the index to disk.
268272
///
269273
/// After this method is called, the workspace will no longer have an index associated with it. It should only be
270274
/// called when SourceKit-LSP shuts down.
271275
func closeIndex() {
272-
uncheckedIndex.value = nil
276+
_uncheckedIndex.value = nil
273277
}
274278

275279
public func filesDidChange(_ events: [FileEvent]) async {
276280
await buildSystemManager.filesDidChange(events)
277281
await syntacticTestIndex.filesDidChange(events)
282+
await semanticIndexManager?.filesDidChange(events)
278283
}
279284
}
280285

0 commit comments

Comments
 (0)