Skip to content

Commit d10c868

Browse files
committed
Support indexing a file in the context of multiple targets
If a source file is part of multiple targets, we should index it in the context of all of those targets because the different targets may produce different USRs since they might use different build settings to interpret the file.
1 parent c9a1a08 commit d10c868

16 files changed

+499
-144
lines changed

Contributor Documentation/BSP Extensions.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export interface SourceKitInitializeBuildResponseData {
2929
prepareProvider?: bool;
3030

3131
/** Whether the server implements the `textDocument/sourceKitOptions` request. */
32-
sourceKitOp tionsProvider?: bool;
32+
sourceKitOptionsProvider?: bool;
3333

3434
/** The files to watch for changes.
3535
* Changes to these files are sent to the BSP server using `workspace/didChangeWatchedFiles`.
@@ -94,7 +94,7 @@ export interface SourceKitSourceItemData {
9494
* in the compiler arguments or the file that is passed as `-o`, if `-index-unit-output-path` is not specified.
9595
*
9696
* This allows SourceKit-LSP to remove index entries for source files that are removed from a target but remain
97-
* present on disk.
97+
* present on disk and to index a file that is part of multiple targets in the context of each target.
9898
*
9999
* The server communicates during the initialize handshake whether it populates this property by setting
100100
* `outputPathsProvider: true` in `SourceKitInitializeBuildResponseData`.

Sources/BuildServerProtocol/Messages/BuildTargetSourcesRequest.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,14 @@ public struct SourceItem: Codable, Hashable, Sendable {
7171
/// Language-specific metadata about this source item.
7272
public var data: LSPAny?
7373

74+
/// If `dataKind` is `sourceKit`, the `data` interpreted as `SourceKitSourceItemData`, otherwise `nil`.
75+
public var sourceKitData: SourceKitSourceItemData? {
76+
guard dataKind == .sourceKit else {
77+
return nil
78+
}
79+
return SourceKitSourceItemData(fromLSPAny: data)
80+
}
81+
7482
public init(
7583
uri: URI,
7684
kind: SourceItemKind,

Sources/BuildSystemIntegration/BuildSystemManager.swift

Lines changed: 97 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,40 @@ import struct TSCBasic.RelativePath
2626

2727
fileprivate typealias RequestCache<Request: RequestType & Hashable> = Cache<Request, Request.Response>
2828

29+
/// An output path returned from the build server in the `SourceItem.data.outputPath` field.
30+
package enum OutputPath: Hashable, Comparable, CustomLogStringConvertible {
31+
/// An output path returned from the build server.
32+
case path(String)
33+
34+
/// The build server does not support output paths.
35+
case notSupported
36+
37+
package var description: String {
38+
switch self {
39+
case .notSupported: return "<output path not supported>"
40+
case .path(let path): return path
41+
}
42+
}
43+
44+
package var redactedDescription: String {
45+
switch self {
46+
case .notSupported: return "<output path not supported>"
47+
case .path(let path): return path.hashForLogging
48+
}
49+
}
50+
}
51+
2952
package struct SourceFileInfo: Sendable {
53+
/// Maps the targets that this source file is a member of to the output path the file has within that target.
54+
///
55+
/// The value in the dictionary can be:
56+
/// - `.path` if the build server supports output paths and produced a result
57+
/// - `.notSupported` if the build server does not support output paths.
58+
/// - `nil` if the build server supports output paths but did not return an output path for this file in this target.
59+
package var targetsToOutputPaths: [BuildTargetIdentifier: OutputPath?]
60+
3061
/// The targets that this source file is a member of
31-
package var targets: Set<BuildTargetIdentifier>
62+
package var targets: some Collection<BuildTargetIdentifier> & Sendable { targetsToOutputPaths.keys }
3263

3364
/// `true` if this file belongs to the root project that the user is working on. It is false, if the file belongs
3465
/// to a dependency of the project.
@@ -48,8 +79,24 @@ package struct SourceFileInfo: Sendable {
4879
guard let other else {
4980
return self
5081
}
82+
let mergedTargetsToOutputPaths = targetsToOutputPaths.merging(
83+
other.targetsToOutputPaths,
84+
uniquingKeysWith: { lhs, rhs in
85+
if lhs == rhs {
86+
return lhs
87+
}
88+
logger.error("Received mismatching output files: \(lhs?.forLogging) vs \(rhs?.forLogging)")
89+
// Deterministically pick an output file if they mismatch. But really, this shouldn't happen.
90+
switch (lhs, rhs) {
91+
case (let lhs?, nil): return lhs
92+
case (nil, let rhs?): return rhs
93+
case (nil, nil): return nil // Should be handled above already
94+
case (let lhs?, let rhs?): return min(lhs, rhs)
95+
}
96+
}
97+
)
5198
return SourceFileInfo(
52-
targets: targets.union(other.targets),
99+
targetsToOutputPaths: mergedTargetsToOutputPaths,
53100
isPartOfRootProject: other.isPartOfRootProject || isPartOfRootProject,
54101
mayContainTests: other.mayContainTests || mayContainTests,
55102
isBuildable: other.isBuildable || isBuildable
@@ -69,15 +116,6 @@ private struct BuildTargetInfo {
69116
var dependents: Set<BuildTargetIdentifier>
70117
}
71118

72-
fileprivate extension SourceItem {
73-
var sourceKitData: SourceKitSourceItemData? {
74-
guard dataKind == .sourceKit else {
75-
return nil
76-
}
77-
return SourceKitSourceItemData(fromLSPAny: data)
78-
}
79-
}
80-
81119
fileprivate extension BuildTarget {
82120
var sourceKitData: SourceKitBuildTarget? {
83121
guard dataKind == .sourceKit else {
@@ -743,26 +781,52 @@ package actor BuildSystemManager: QueueBasedMessageHandler {
743781
return languageFromBuildSystem ?? Language(inferredFromFileExtension: document)
744782
}
745783

746-
/// Returns all the targets that the document is part of.
747-
package func targets(for document: DocumentURI) async -> Set<BuildTargetIdentifier> {
784+
/// Retrieve information about the given source file within the build server.
785+
package func sourceFileInfo(for document: DocumentURI) async -> SourceFileInfo? {
748786
return await orLog("Getting targets for source file") {
749-
var result: Set<BuildTargetIdentifier> = []
787+
var result: SourceFileInfo? = nil
750788
let filesAndDirectories = try await sourceFilesAndDirectories()
751-
if let targets = filesAndDirectories.files[document]?.targets {
752-
result.formUnion(targets)
789+
if let info = filesAndDirectories.files[document] {
790+
result = result?.merging(info) ?? info
753791
}
754792
if !filesAndDirectories.directories.isEmpty, let documentPathComponents = document.fileURL?.pathComponents {
755793
for (_, (directoryPathComponents, info)) in filesAndDirectories.directories {
756794
guard let directoryPathComponents else {
757795
continue
758796
}
759797
if isDescendant(documentPathComponents, of: directoryPathComponents) {
760-
result.formUnion(info.targets)
798+
result = result?.merging(info) ?? info
761799
}
762800
}
763801
}
764802
return result
765-
} ?? []
803+
}
804+
}
805+
806+
/// Returns all the targets that the document is part of.
807+
package func targets(for document: DocumentURI) async -> [BuildTargetIdentifier] {
808+
guard let targets = await sourceFileInfo(for: document)?.targets else {
809+
return []
810+
}
811+
return Array(targets)
812+
}
813+
814+
/// The `OutputPath` with which `uri` should be indexed in `target`. This may return:
815+
/// - `.path` if the build server supports output paths and produced a result
816+
/// - `.notSupported` if the build server does not support output paths. In this case we will assume that the index
817+
/// for `uri` is up-to-date if we have any up-to-date unit for it.
818+
/// - `nil` if the build server supports output paths but did not return an output path for `uri` in `target`.
819+
package func outputPath(for document: DocumentURI, in target: BuildTargetIdentifier) async throws -> OutputPath? {
820+
guard await initializationData?.outputPathsProvider ?? false else {
821+
// Early exit if the build server doesn't support output paths.
822+
return .notSupported
823+
}
824+
guard let sourceFileInfo = await sourceFileInfo(for: document),
825+
let outputPath = sourceFileInfo.targetsToOutputPaths[target]
826+
else {
827+
return nil
828+
}
829+
return outputPath
766830
}
767831

768832
/// Returns the `BuildTargetIdentifier` that should be used for semantic functionality of the given document.
@@ -1045,7 +1109,7 @@ package actor BuildSystemManager: QueueBasedMessageHandler {
10451109

10461110
/// Returns the list of targets that might depend on the given target and that need to be re-prepared when a file in
10471111
/// `target` is modified.
1048-
package func targets(dependingOn targetIds: Set<BuildTargetIdentifier>) async -> [BuildTargetIdentifier] {
1112+
package func targets(dependingOn targetIds: some Collection<BuildTargetIdentifier>) async -> [BuildTargetIdentifier] {
10491113
guard
10501114
let buildTargets = await orLog("Getting build targets for dependents", { try await self.buildTargets() })
10511115
else {
@@ -1144,7 +1208,7 @@ package actor BuildSystemManager: QueueBasedMessageHandler {
11441208
/// Return the output paths for all source files known to the build server.
11451209
///
11461210
/// See `SourceKitSourceItemData.outputFilePath` for details.
1147-
package func outputPathInAllTargets() async throws -> [String] {
1211+
package func outputPathsInAllTargets() async throws -> [String] {
11481212
return try await outputPaths(in: Set(buildTargets().map(\.key)))
11491213
}
11501214

@@ -1180,6 +1244,8 @@ package actor BuildSystemManager: QueueBasedMessageHandler {
11801244
/// - Important: This method returns both buildable and non-buildable source files. Callers need to check
11811245
/// `SourceFileInfo.isBuildable` if they are only interested in buildable source files.
11821246
private func sourceFilesAndDirectories() async throws -> SourceFilesAndDirectories {
1247+
let supportsOutputPaths = await initializationData?.outputPathsProvider ?? false
1248+
11831249
return try await cachedSourceFilesAndDirectories.get(
11841250
SourceFilesAndDirectoriesKey(),
11851251
isolation: self
@@ -1194,12 +1260,21 @@ package actor BuildSystemManager: QueueBasedMessageHandler {
11941260
let isPartOfRootProject = !(target?.tags.contains(.dependency) ?? false)
11951261
let mayContainTests = target?.tags.contains(.test) ?? true
11961262
for sourceItem in sourcesItem.sources {
1263+
let sourceKitData = sourceItem.sourceKitData
1264+
let outputPath: OutputPath? =
1265+
if !supportsOutputPaths {
1266+
.notSupported
1267+
} else if let outputPath = sourceKitData?.outputPath {
1268+
.path(outputPath)
1269+
} else {
1270+
nil
1271+
}
11971272
let info = SourceFileInfo(
1198-
targets: [sourcesItem.target],
1273+
targetsToOutputPaths: [sourcesItem.target: outputPath],
11991274
isPartOfRootProject: isPartOfRootProject,
12001275
mayContainTests: mayContainTests,
12011276
isBuildable: !(target?.tags.contains(.notBuildable) ?? false)
1202-
&& !(sourceItem.sourceKitData?.isHeader ?? false)
1277+
&& !(sourceKitData?.isHeader ?? false)
12031278
)
12041279
switch sourceItem.kind {
12051280
case .file:

Sources/SKTestSupport/CustomBuildServerTestProject.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ package extension CustomBuildServer {
178178
initializeData: SourceKitInitializeBuildResponseData(
179179
indexDatabasePath: try projectRoot.appendingPathComponent("index-db").filePath,
180180
indexStorePath: try projectRoot.appendingPathComponent("index-store").filePath,
181+
outputPathsProvider: true,
181182
prepareProvider: true,
182183
sourceKitOptionsProvider: true
183184
)

Sources/SemanticIndex/CheckedIndex.swift

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
//
1111
//===----------------------------------------------------------------------===//
1212

13+
package import BuildSystemIntegration
1314
package import Foundation
1415
@preconcurrency package import IndexStoreDB
1516
package import LanguageServerProtocol
@@ -129,7 +130,7 @@ package final class CheckedIndex {
129130
}
130131

131132
package func symbols(inFilePath path: String) -> [Symbol] {
132-
guard self.hasUpToDateUnit(for: DocumentURI(filePath: path, isDirectory: false)) else {
133+
guard self.hasAnyUpToDateUnit(for: DocumentURI(filePath: path, isDirectory: false)) else {
133134
return []
134135
}
135136
return index.symbols(inFilePath: path)
@@ -147,15 +148,28 @@ package final class CheckedIndex {
147148

148149
/// Return `true` if a unit file has been indexed for the given file path after its last modification date.
149150
///
150-
/// This means that at least a single build configuration of this file has been indexed since its last modification.
151-
///
152151
/// If `mainFile` is passed, then `url` is a header file that won't have a unit associated with it. `mainFile` is
153-
/// assumed to be a file that imports `url`. To check that `url` has an up-to-date unit, check that the latest unit
154-
/// for `mainFile` is newer than the mtime of the header file at `url`.
155-
package func hasUpToDateUnit(for uri: DocumentURI, mainFile: DocumentURI? = nil) -> Bool {
152+
///
153+
/// This means that at least a single build configuration of this file has been indexed since its last modification.
154+
/// This method does not care about which target (identified by output path in the index) produced the up-to-date
155+
/// unit.
156+
package func hasAnyUpToDateUnit(for uri: DocumentURI, mainFile: DocumentURI? = nil) -> Bool {
156157
return checker.indexHasUpToDateUnit(for: uri, mainFile: mainFile, index: index)
157158
}
158159

160+
/// Return `true` if a unit file with the given output path has been indexed after its last modification date of
161+
/// `uri`.
162+
///
163+
/// If `outputPath` is `notSupported`, this behaves the same as `hasAnyUpToDateUnit`.
164+
package func hasUpToDateUnit(for uri: DocumentURI, mainFile: DocumentURI? = nil, outputPath: OutputPath) -> Bool {
165+
switch outputPath {
166+
case .path(let outputPath):
167+
return checker.indexHasUpToDateUnit(for: uri, outputPath: outputPath, index: index)
168+
case .notSupported:
169+
return self.hasAnyUpToDateUnit(for: uri, mainFile: mainFile)
170+
}
171+
}
172+
159173
/// Returns true if the file at the given URI has a different content in the document manager than on-disk. This is
160174
/// the case if the user made edits to the file but didn't save them yet.
161175
///
@@ -393,14 +407,9 @@ private struct IndexOutOfDateChecker {
393407
}
394408
}
395409

396-
/// Return `true` if a unit file has been indexed for the given file path after its last modification date.
397-
///
398-
/// This means that at least a single build configuration of this file has been indexed since its last modification.
399-
///
400-
/// If `mainFile` is passed, then `filePath` is a header file that won't have a unit associated with it. `mainFile` is
401-
/// assumed to be a file that imports `url`. To check that `url` has an up-to-date unit, check that the latest unit
402-
/// for `mainFile` is newer than the mtime of the header file at `url`.
403-
mutating func indexHasUpToDateUnit(for filePath: DocumentURI, mainFile: DocumentURI?, index: IndexStoreDB) -> Bool {
410+
/// Checks if we have a unit that's up to date for the given source file, assuming that the unit in question has been
411+
/// modified at the date returned by `unitModificationDate`.
412+
private mutating func unitIsUpToDate(for filePath: DocumentURI, unitModificationDate: () -> Date?) -> Bool {
404413
switch checkLevel {
405414
case .inMemoryModifiedFiles(let documentManager):
406415
if fileHasInMemoryModifications(filePath, documentManager: documentManager) {
@@ -411,10 +420,7 @@ private struct IndexOutOfDateChecker {
411420
// If there are no in-memory modifications check if there are on-disk modifications.
412421
fallthrough
413422
case .modifiedFiles:
414-
guard
415-
let filePathStr = orLog("Realpath for up-to-date", { try (mainFile ?? filePath).fileURL?.realpath.filePath }),
416-
let lastUnitDate = index.dateOfLatestUnitFor(filePath: filePathStr)
417-
else {
423+
guard let lastUnitDate = unitModificationDate() else {
418424
return false
419425
}
420426
do {
@@ -436,6 +442,33 @@ private struct IndexOutOfDateChecker {
436442
}
437443
}
438444

445+
/// Return `true` if a unit file has been indexed for the given file path after its last modification date.
446+
///
447+
/// This means that at least a single build configuration of this file has been indexed since its last modification.
448+
mutating func indexHasUpToDateUnit(for filePath: DocumentURI, mainFile: DocumentURI?, index: IndexStoreDB) -> Bool {
449+
return unitIsUpToDate(
450+
for: filePath,
451+
unitModificationDate: {
452+
let filePathStr = orLog("Realpath for up-to-date", { try (mainFile ?? filePath).fileURL?.realpath.filePath })
453+
guard let filePathStr else {
454+
return nil
455+
}
456+
return index.dateOfLatestUnitFor(filePath: filePathStr)
457+
}
458+
)
459+
}
460+
461+
/// Return `true` if a unit file has been indexed for the given file path after its last modification date.
462+
///
463+
/// This means that at least a single build configuration of this file has been indexed since its last modification.
464+
///
465+
/// If `mainFile` is passed, then `filePath` is a header file that won't have a unit associated with it. `mainFile` is
466+
/// assumed to be a file that imports `url`. To check that `url` has an up-to-date unit, check that the latest unit
467+
/// for `mainFile` is newer than the mtime of the header file at `url`.
468+
mutating func indexHasUpToDateUnit(for filePath: DocumentURI, outputPath: String, index: IndexStoreDB) -> Bool {
469+
return unitIsUpToDate(for: filePath, unitModificationDate: { index.dateOfUnitFor(outputPath: outputPath) })
470+
}
471+
439472
// MARK: - Cached check primitives
440473

441474
/// `documentManager` must always be the same between calls to `hasFileInMemoryModifications` since it is not part of

Sources/SemanticIndex/PreparationTaskDescription.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ package struct PreparationTaskDescription: IndexTaskDescription {
3737
/// The build system manager that is used to get the toolchain and build settings for the files to index.
3838
private let buildSystemManager: BuildSystemManager
3939

40-
private let preparationUpToDateTracker: UpToDateTracker<BuildTargetIdentifier>
40+
private let preparationUpToDateTracker: UpToDateTracker<BuildTargetIdentifier, DummySecondaryKey>
4141

4242
/// See `SemanticIndexManager.logMessageToIndexLog`.
4343
private let logMessageToIndexLog:
@@ -62,7 +62,7 @@ package struct PreparationTaskDescription: IndexTaskDescription {
6262
init(
6363
targetsToPrepare: [BuildTargetIdentifier],
6464
buildSystemManager: BuildSystemManager,
65-
preparationUpToDateTracker: UpToDateTracker<BuildTargetIdentifier>,
65+
preparationUpToDateTracker: UpToDateTracker<BuildTargetIdentifier, DummySecondaryKey>,
6666
logMessageToIndexLog: @escaping @Sendable (
6767
_ message: String, _ type: WindowMessageType, _ structure: StructuredLogKind
6868
) -> Void,

0 commit comments

Comments
 (0)