Skip to content

Commit b551d87

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 95b7cfb commit b551d87

15 files changed

+490
-139
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
@@ -75,6 +75,14 @@ public struct SourceItem: Codable, Hashable, Sendable {
7575
/// Language-specific metadata about this source item.
7676
public var data: LSPAny?
7777

78+
/// If `dataKind` is `sourceKit`, the `data` interpreted as `SourceKitSourceItemData`, otherwise `nil`.
79+
public var sourceKitData: SourceKitSourceItemData? {
80+
guard dataKind == .sourceKit else {
81+
return nil
82+
}
83+
return SourceKitSourceItemData(fromLSPAny: data)
84+
}
85+
7886
public init(
7987
uri: URI,
8088
kind: SourceItemKind,

Sources/BuildSystemIntegration/BuildSystemManager.swift

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

4343
fileprivate typealias RequestCache<Request: RequestType & Hashable> = Cache<Request, Request.Response>
4444

45+
/// An output path returned from the build server in the `SourceItem.data.outputPath` field.
46+
package enum OutputPath: Hashable, Comparable, CustomLogStringConvertible {
47+
/// An output path returned from the build server.
48+
case path(String)
49+
50+
/// The build server does not support output paths.
51+
case notSupported
52+
53+
package var description: String {
54+
switch self {
55+
case .notSupported: return "<output path not supported>"
56+
case .path(let path): return path
57+
}
58+
}
59+
60+
package var redactedDescription: String {
61+
switch self {
62+
case .notSupported: return "<output path not supported>"
63+
case .path(let path): return path.hashForLogging
64+
}
65+
}
66+
}
67+
4568
package struct SourceFileInfo: Sendable {
69+
/// Maps the targets that this source file is a member of to the output path the file has within that target.
70+
///
71+
/// The value in the dictionary can be:
72+
/// - `.path` if the build server supports output paths and produced a result
73+
/// - `.notSupported` if the build server does not support output paths.
74+
/// - `nil` if the build server supports output paths but did not return an output path for this file in this target.
75+
package var targetsToOutputPaths: [BuildTargetIdentifier: OutputPath?]
76+
4677
/// The targets that this source file is a member of
47-
package var targets: Set<BuildTargetIdentifier>
78+
package var targets: some Collection<BuildTargetIdentifier> & Sendable { targetsToOutputPaths.keys }
4879

4980
/// `true` if this file belongs to the root project that the user is working on. It is false, if the file belongs
5081
/// to a dependency of the project.
@@ -64,8 +95,24 @@ package struct SourceFileInfo: Sendable {
6495
guard let other else {
6596
return self
6697
}
98+
let mergedTargetsToOutputPaths = targetsToOutputPaths.merging(
99+
other.targetsToOutputPaths,
100+
uniquingKeysWith: { lhs, rhs in
101+
if lhs == rhs {
102+
return lhs
103+
}
104+
logger.error("Received mismatching output files: \(lhs?.forLogging) vs \(rhs?.forLogging)")
105+
// Deterministically pick an output file if they mismatch. But really, this shouldn't happen.
106+
switch (lhs, rhs) {
107+
case (let lhs?, nil): return lhs
108+
case (nil, let rhs?): return rhs
109+
case (nil, nil): return nil // Should be handled above already
110+
case (let lhs?, let rhs?): return min(lhs, rhs)
111+
}
112+
}
113+
)
67114
return SourceFileInfo(
68-
targets: targets.union(other.targets),
115+
targetsToOutputPaths: mergedTargetsToOutputPaths,
69116
isPartOfRootProject: other.isPartOfRootProject || isPartOfRootProject,
70117
mayContainTests: other.mayContainTests || mayContainTests,
71118
isBuildable: other.isBuildable || isBuildable
@@ -85,15 +132,6 @@ private struct BuildTargetInfo {
85132
var dependents: Set<BuildTargetIdentifier>
86133
}
87134

88-
fileprivate extension SourceItem {
89-
var sourceKitData: SourceKitSourceItemData? {
90-
guard dataKind == .sourceKit else {
91-
return nil
92-
}
93-
return SourceKitSourceItemData(fromLSPAny: data)
94-
}
95-
}
96-
97135
fileprivate extension BuildTarget {
98136
var sourceKitData: SourceKitBuildTarget? {
99137
guard dataKind == .sourceKit else {
@@ -759,26 +797,52 @@ package actor BuildSystemManager: QueueBasedMessageHandler {
759797
return languageFromBuildSystem ?? Language(inferredFromFileExtension: document)
760798
}
761799

762-
/// Returns all the targets that the document is part of.
763-
package func targets(for document: DocumentURI) async -> Set<BuildTargetIdentifier> {
800+
/// Retrieve information about the given source file within the build server.
801+
package func sourceFileInfo(for document: DocumentURI) async -> SourceFileInfo? {
764802
return await orLog("Getting targets for source file") {
765-
var result: Set<BuildTargetIdentifier> = []
803+
var result: SourceFileInfo? = nil
766804
let filesAndDirectories = try await sourceFilesAndDirectories()
767-
if let targets = filesAndDirectories.files[document]?.targets {
768-
result.formUnion(targets)
805+
if let info = filesAndDirectories.files[document] {
806+
result = result?.merging(info) ?? info
769807
}
770808
if !filesAndDirectories.directories.isEmpty, let documentPathComponents = document.fileURL?.pathComponents {
771809
for (_, (directoryPathComponents, info)) in filesAndDirectories.directories {
772810
guard let directoryPathComponents else {
773811
continue
774812
}
775813
if isDescendant(documentPathComponents, of: directoryPathComponents) {
776-
result.formUnion(info.targets)
814+
result = result?.merging(info) ?? info
777815
}
778816
}
779817
}
780818
return result
781-
} ?? []
819+
}
820+
}
821+
822+
/// Returns all the targets that the document is part of.
823+
package func targets(for document: DocumentURI) async -> [BuildTargetIdentifier] {
824+
guard let targets = await sourceFileInfo(for: document)?.targets else {
825+
return []
826+
}
827+
return Array(targets)
828+
}
829+
830+
/// The `OutputPath` with which `uri` should be indexed in `target`. This may return:
831+
/// - `.path` if the build server supports output paths and produced a result
832+
/// - `.notSupported` if the build server does not support output paths. In this case we will assume that the index
833+
/// for `uri` is up-to-date if we have any up-to-date unit for it.
834+
/// - `nil` if the build server supports output paths but did not return an output path for `uri` in `target`.
835+
package func outputPath(for document: DocumentURI, in target: BuildTargetIdentifier) async throws -> OutputPath? {
836+
guard await initializationData?.outputPathsProvider ?? false else {
837+
// Early exit if the build server doesn't support output paths.
838+
return .notSupported
839+
}
840+
guard let sourceFileInfo = await sourceFileInfo(for: document),
841+
let outputPath = sourceFileInfo.targetsToOutputPaths[target]
842+
else {
843+
return nil
844+
}
845+
return outputPath
782846
}
783847

784848
/// Returns the `BuildTargetIdentifier` that should be used for semantic functionality of the given document.
@@ -1061,7 +1125,7 @@ package actor BuildSystemManager: QueueBasedMessageHandler {
10611125

10621126
/// Returns the list of targets that might depend on the given target and that need to be re-prepared when a file in
10631127
/// `target` is modified.
1064-
package func targets(dependingOn targetIds: Set<BuildTargetIdentifier>) async -> [BuildTargetIdentifier] {
1128+
package func targets(dependingOn targetIds: some Collection<BuildTargetIdentifier>) async -> [BuildTargetIdentifier] {
10651129
guard
10661130
let buildTargets = await orLog("Getting build targets for dependents", { try await self.buildTargets() })
10671131
else {
@@ -1160,7 +1224,7 @@ package actor BuildSystemManager: QueueBasedMessageHandler {
11601224
/// Return the output paths for all source files known to the build server.
11611225
///
11621226
/// See `SourceKitSourceItemData.outputFilePath` for details.
1163-
package func outputPathInAllTargets() async throws -> [String] {
1227+
package func outputPathsInAllTargets() async throws -> [String] {
11641228
return try await outputPaths(in: Set(buildTargets().map(\.key)))
11651229
}
11661230

@@ -1196,6 +1260,8 @@ package actor BuildSystemManager: QueueBasedMessageHandler {
11961260
/// - Important: This method returns both buildable and non-buildable source files. Callers need to check
11971261
/// `SourceFileInfo.isBuildable` if they are only interested in buildable source files.
11981262
private func sourceFilesAndDirectories() async throws -> SourceFilesAndDirectories {
1263+
let supportsOutputPaths = await initializationData?.outputPathsProvider ?? false
1264+
11991265
return try await cachedSourceFilesAndDirectories.get(
12001266
SourceFilesAndDirectoriesKey(),
12011267
isolation: self
@@ -1210,12 +1276,21 @@ package actor BuildSystemManager: QueueBasedMessageHandler {
12101276
let isPartOfRootProject = !(target?.tags.contains(.dependency) ?? false)
12111277
let mayContainTests = target?.tags.contains(.test) ?? true
12121278
for sourceItem in sourcesItem.sources {
1279+
let sourceKitData = sourceItem.sourceKitData
1280+
let outputPath: OutputPath? =
1281+
if !supportsOutputPaths {
1282+
.notSupported
1283+
} else if let outputPath = sourceKitData?.outputPath {
1284+
.path(outputPath)
1285+
} else {
1286+
nil
1287+
}
12131288
let info = SourceFileInfo(
1214-
targets: [sourcesItem.target],
1289+
targetsToOutputPaths: [sourcesItem.target: outputPath],
12151290
isPartOfRootProject: isPartOfRootProject,
12161291
mayContainTests: mayContainTests,
12171292
isBuildable: !(target?.tags.contains(.notBuildable) ?? false)
1218-
&& !(sourceItem.sourceKitData?.isHeader ?? false)
1293+
&& !(sourceKitData?.isHeader ?? false)
12191294
)
12201295
switch sourceItem.kind {
12211296
case .file:

Sources/SKTestSupport/CustomBuildServerTestProject.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ package extension CustomBuildServer {
133133
initializeData: SourceKitInitializeBuildResponseData(
134134
indexDatabasePath: try projectRoot.appendingPathComponent("index-db").filePath,
135135
indexStorePath: try projectRoot.appendingPathComponent("index-store").filePath,
136+
outputPathsProvider: true,
136137
prepareProvider: true,
137138
sourceKitOptionsProvider: true
138139
)

Sources/SemanticIndex/CheckedIndex.swift

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@
1111
//===----------------------------------------------------------------------===//
1212

1313
#if compiler(>=6)
14+
package import BuildSystemIntegration
1415
package import Foundation
1516
@preconcurrency package import IndexStoreDB
1617
package import LanguageServerProtocol
1718
import SKLogging
1819
import SwiftExtensions
1920
#else
21+
import BuildSystemIntegration
2022
import Foundation
2123
@preconcurrency import IndexStoreDB
2224
import LanguageServerProtocol
@@ -137,7 +139,7 @@ package final class CheckedIndex {
137139
}
138140

139141
package func symbols(inFilePath path: String) -> [Symbol] {
140-
guard self.hasUpToDateUnit(for: DocumentURI(filePath: path, isDirectory: false)) else {
142+
guard self.hasAnyUpToDateUnit(for: DocumentURI(filePath: path, isDirectory: false)) else {
141143
return []
142144
}
143145
return index.symbols(inFilePath: path)
@@ -155,15 +157,28 @@ package final class CheckedIndex {
155157

156158
/// Return `true` if a unit file has been indexed for the given file path after its last modification date.
157159
///
158-
/// This means that at least a single build configuration of this file has been indexed since its last modification.
159-
///
160160
/// If `mainFile` is passed, then `url` is a header file that won't have a unit associated with it. `mainFile` is
161-
/// assumed to be a file that imports `url`. To check that `url` has an up-to-date unit, check that the latest unit
162-
/// for `mainFile` is newer than the mtime of the header file at `url`.
163-
package func hasUpToDateUnit(for uri: DocumentURI, mainFile: DocumentURI? = nil) -> Bool {
161+
///
162+
/// This means that at least a single build configuration of this file has been indexed since its last modification.
163+
/// This method does not care about which target (identified by output path in the index) produced the up-to-date
164+
/// unit.
165+
package func hasAnyUpToDateUnit(for uri: DocumentURI, mainFile: DocumentURI? = nil) -> Bool {
164166
return checker.indexHasUpToDateUnit(for: uri, mainFile: mainFile, index: index)
165167
}
166168

169+
/// Return `true` if a unit file with the given output path has been indexed after its last modification date of
170+
/// `uri`.
171+
///
172+
/// If `outputPath` is `notSupported`, this behaves the same as `hasAnyUpToDateUnit`.
173+
package func hasUpToDateUnit(for uri: DocumentURI, mainFile: DocumentURI? = nil, outputPath: OutputPath) -> Bool {
174+
switch outputPath {
175+
case .path(let outputPath):
176+
return checker.indexHasUpToDateUnit(for: uri, outputPath: outputPath, index: index)
177+
case .notSupported:
178+
return self.hasAnyUpToDateUnit(for: uri, mainFile: mainFile)
179+
}
180+
}
181+
167182
/// Returns true if the file at the given URI has a different content in the document manager than on-disk. This is
168183
/// the case if the user made edits to the file but didn't save them yet.
169184
///
@@ -401,14 +416,9 @@ private struct IndexOutOfDateChecker {
401416
}
402417
}
403418

404-
/// Return `true` if a unit file has been indexed for the given file path after its last modification date.
405-
///
406-
/// This means that at least a single build configuration of this file has been indexed since its last modification.
407-
///
408-
/// If `mainFile` is passed, then `filePath` is a header file that won't have a unit associated with it. `mainFile` is
409-
/// assumed to be a file that imports `url`. To check that `url` has an up-to-date unit, check that the latest unit
410-
/// for `mainFile` is newer than the mtime of the header file at `url`.
411-
mutating func indexHasUpToDateUnit(for filePath: DocumentURI, mainFile: DocumentURI?, index: IndexStoreDB) -> Bool {
419+
/// Checks if we have a unit that's up to date for the given source file, assuming that the unit in question has been
420+
/// modified at the date returned by `unitModificationDate`.
421+
private mutating func unitIsUpToDate(for filePath: DocumentURI, unitModificationDate: () -> Date?) -> Bool {
412422
switch checkLevel {
413423
case .inMemoryModifiedFiles(let documentManager):
414424
if fileHasInMemoryModifications(filePath, documentManager: documentManager) {
@@ -419,10 +429,7 @@ private struct IndexOutOfDateChecker {
419429
// If there are no in-memory modifications check if there are on-disk modifications.
420430
fallthrough
421431
case .modifiedFiles:
422-
guard
423-
let filePathStr = orLog("Realpath for up-to-date", { try (mainFile ?? filePath).fileURL?.realpath.filePath }),
424-
let lastUnitDate = index.dateOfLatestUnitFor(filePath: filePathStr)
425-
else {
432+
guard let lastUnitDate = unitModificationDate() else {
426433
return false
427434
}
428435
do {
@@ -444,6 +451,33 @@ private struct IndexOutOfDateChecker {
444451
}
445452
}
446453

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

449483
/// `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
@@ -50,7 +50,7 @@ package struct PreparationTaskDescription: IndexTaskDescription {
5050
/// The build system manager that is used to get the toolchain and build settings for the files to index.
5151
private let buildSystemManager: BuildSystemManager
5252

53-
private let preparationUpToDateTracker: UpToDateTracker<BuildTargetIdentifier>
53+
private let preparationUpToDateTracker: UpToDateTracker<BuildTargetIdentifier, DummySecondaryKey>
5454

5555
/// See `SemanticIndexManager.logMessageToIndexLog`.
5656
private let logMessageToIndexLog:
@@ -75,7 +75,7 @@ package struct PreparationTaskDescription: IndexTaskDescription {
7575
init(
7676
targetsToPrepare: [BuildTargetIdentifier],
7777
buildSystemManager: BuildSystemManager,
78-
preparationUpToDateTracker: UpToDateTracker<BuildTargetIdentifier>,
78+
preparationUpToDateTracker: UpToDateTracker<BuildTargetIdentifier, DummySecondaryKey>,
7979
logMessageToIndexLog: @escaping @Sendable (
8080
_ message: String, _ type: WindowMessageType, _ structure: StructuredLogKind
8181
) -> Void,

0 commit comments

Comments
 (0)