Skip to content

Commit 7a06eb0

Browse files
authored
Merge pull request #1858 from ahoppen/symlink-target
Fix background indexing behavior if a source file is included in two targets via a symlink
2 parents e7f9530 + 5eb460f commit 7a06eb0

File tree

7 files changed

+132
-9
lines changed

7 files changed

+132
-9
lines changed

Sources/BuildSystemIntegration/BuildSystemManager.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1033,11 +1033,11 @@ package actor BuildSystemManager: QueueBasedMessageHandler {
10331033
return response.items
10341034
}
10351035

1036-
/// Returns all source files in the project that can be built.
1036+
/// Returns all source files in the project.
10371037
///
10381038
/// - SeeAlso: Comment in `sourceFilesAndDirectories` for a definition of what `buildable` means.
1039-
package func buildableSourceFiles() async throws -> [DocumentURI: SourceFileInfo] {
1040-
return try await sourceFilesAndDirectories(includeNonBuildableFiles: false).files
1039+
package func sourceFiles(includeNonBuildableFiles: Bool) async throws -> [DocumentURI: SourceFileInfo] {
1040+
return try await sourceFilesAndDirectories(includeNonBuildableFiles: includeNonBuildableFiles).files
10411041
}
10421042

10431043
/// Get all files and directories that are known to the build system, ie. that are returned by a `buildTarget/sources`
@@ -1093,7 +1093,7 @@ package actor BuildSystemManager: QueueBasedMessageHandler {
10931093
}
10941094

10951095
package func testFiles() async throws -> [DocumentURI] {
1096-
return try await buildableSourceFiles().compactMap { (uri, info) -> DocumentURI? in
1096+
return try await sourceFiles(includeNonBuildableFiles: false).compactMap { (uri, info) -> DocumentURI? in
10971097
guard info.isPartOfRootProject, info.mayContainTests else {
10981098
return nil
10991099
}

Sources/BuildSystemIntegration/SwiftPMBuildSystem.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -587,7 +587,7 @@ package actor SwiftPMBuildSystem: BuiltInBuildSystem {
587587
SourceItem(
588588
uri: DocumentURI($0),
589589
kind: $0.isDirectory ? .directory : .file,
590-
generated: false,
590+
generated: false
591591
)
592592
}
593593
result.append(SourcesItem(target: target, sources: sources))

Sources/SKTestSupport/MultiFileTestProject.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,9 @@ package class MultiFileTestProject {
8888
/// File contents can also contain `$TEST_DIR`, which gets replaced by the temporary directory.
8989
package init(
9090
files: [RelativeFileLocation: String],
91-
workspaces: (URL) async throws -> [WorkspaceFolder] = { [WorkspaceFolder(uri: DocumentURI($0))] },
91+
workspaces: (_ scratchDirectory: URL) async throws -> [WorkspaceFolder] = {
92+
[WorkspaceFolder(uri: DocumentURI($0))]
93+
},
9294
initializationOptions: LSPAny? = nil,
9395
capabilities: ClientCapabilities = ClientCapabilities(),
9496
options: SourceKitLSPOptions = .testDefault(),

Sources/SKTestSupport/SwiftPMTestProject.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,9 @@ package class SwiftPMTestProject: MultiFileTestProject {
163163
package init(
164164
files: [RelativeFileLocation: String],
165165
manifest: String = SwiftPMTestProject.defaultPackageManifest,
166-
workspaces: (URL) async throws -> [WorkspaceFolder] = { [WorkspaceFolder(uri: DocumentURI($0))] },
166+
workspaces: (_ scratchDirectory: URL) async throws -> [WorkspaceFolder] = {
167+
[WorkspaceFolder(uri: DocumentURI($0))]
168+
},
167169
initializationOptions: LSPAny? = nil,
168170
capabilities: ClientCapabilities = ClientCapabilities(),
169171
options: SourceKitLSPOptions = .testDefault(),

Sources/SemanticIndex/SemanticIndexManager.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,8 @@ package final actor SemanticIndexManager {
291291
filesToIndex
292292
} else {
293293
await orLog("Getting files to index") {
294-
try await self.buildSystemManager.buildableSourceFiles().keys.sorted { $0.stringValue < $1.stringValue }
294+
try await self.buildSystemManager.sourceFiles(includeNonBuildableFiles: false).keys
295+
.sorted { $0.stringValue < $1.stringValue }
295296
} ?? []
296297
}
297298
if !indexFilesWithUpToDateUnit {
@@ -408,7 +409,7 @@ package final actor SemanticIndexManager {
408409
toCover files: some Collection<DocumentURI> & Sendable
409410
) async -> [FileToIndex] {
410411
let sourceFiles = await orLog("Getting source files in project") {
411-
Set(try await buildSystemManager.buildableSourceFiles().keys)
412+
Set(try await buildSystemManager.sourceFiles(includeNonBuildableFiles: false).keys)
412413
}
413414
guard let sourceFiles else {
414415
return []

Sources/SourceKitLSP/Workspace.swift

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,51 @@ fileprivate func firstNonNil<T>(
6262
return try await defaultValue()
6363
}
6464

65+
/// Actor that caches realpaths for `sourceFilesWithSameRealpath`.
66+
fileprivate actor SourceFilesWithSameRealpathInferrer {
67+
private let buildSystemManager: BuildSystemManager
68+
private var realpathCache: [DocumentURI: DocumentURI] = [:]
69+
70+
init(buildSystemManager: BuildSystemManager) {
71+
self.buildSystemManager = buildSystemManager
72+
}
73+
74+
private func realpath(of uri: DocumentURI) -> DocumentURI {
75+
if let cached = realpathCache[uri] {
76+
return cached
77+
}
78+
let value = uri.symlinkTarget ?? uri
79+
realpathCache[uri] = value
80+
return value
81+
}
82+
83+
/// Returns the URIs of all source files in the project that have the same realpath as a document in `documents` but
84+
/// are not in `documents`.
85+
///
86+
/// This is useful in the following scenario: A project has target A containing A.swift an target B containing B.swift
87+
/// B.swift is a symlink to A.swift. When A.swift is modified, both the dependencies of A and B need to be marked as
88+
/// having an out-of-date preparation status, not just A.
89+
package func sourceFilesWithSameRealpath(as documents: [DocumentURI]) async -> [DocumentURI] {
90+
let realPaths = Set(documents.map { realpath(of: $0) })
91+
return await orLog("Determining source files with same realpath") {
92+
var result: [DocumentURI] = []
93+
let filesAndDirectories = try await buildSystemManager.sourceFiles(includeNonBuildableFiles: true)
94+
for file in filesAndDirectories.keys {
95+
if realPaths.contains(realpath(of: file)) && !documents.contains(file) {
96+
result.append(file)
97+
}
98+
}
99+
return result
100+
} ?? []
101+
}
102+
103+
func filesDidChange(_ events: [FileEvent]) {
104+
for event in events {
105+
realpathCache[event.uri] = nil
106+
}
107+
}
108+
}
109+
65110
/// Represents the configuration and state of a project or combination of projects being worked on
66111
/// together.
67112
///
@@ -86,6 +131,8 @@ package final class Workspace: Sendable, BuildSystemManagerDelegate {
86131
/// The build system manager to use for documents in this workspace.
87132
package let buildSystemManager: BuildSystemManager
88133

134+
private let sourceFilesWithSameRealpathInferrer: SourceFilesWithSameRealpathInferrer
135+
89136
let options: SourceKitLSPOptions
90137

91138
/// The source code index, if available.
@@ -126,6 +173,9 @@ package final class Workspace: Sendable, BuildSystemManagerDelegate {
126173
self.options = options
127174
self._uncheckedIndex = ThreadSafeBox(initialValue: uncheckedIndex)
128175
self.buildSystemManager = buildSystemManager
176+
self.sourceFilesWithSameRealpathInferrer = SourceFilesWithSameRealpathInferrer(
177+
buildSystemManager: buildSystemManager
178+
)
129179
if options.backgroundIndexingOrDefault, let uncheckedIndex,
130180
await buildSystemManager.initializationData?.prepareProvider ?? false
131181
{
@@ -316,6 +366,17 @@ package final class Workspace: Sendable, BuildSystemManagerDelegate {
316366
}
317367

318368
package func filesDidChange(_ events: [FileEvent]) async {
369+
// First clear any cached realpaths in `sourceFilesWithSameRealpathInferrer`.
370+
await sourceFilesWithSameRealpathInferrer.filesDidChange(events)
371+
372+
// Now infer any edits for source files that share the same realpath as one of the modified files.
373+
var events = events
374+
events +=
375+
await sourceFilesWithSameRealpathInferrer
376+
.sourceFilesWithSameRealpath(as: events.filter { $0.type == .changed }.map(\.uri))
377+
.map { FileEvent(uri: $0, type: .changed) }
378+
379+
// Notify all clients about the reported and inferred edits.
319380
await buildSystemManager.filesDidChange(events)
320381
await syntacticTestIndex.filesDidChange(events)
321382
await semanticIndexManager?.filesDidChange(events)

Tests/SourceKitLSPTests/BackgroundIndexingTests.swift

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1620,6 +1620,63 @@ final class BackgroundIndexingTests: XCTestCase {
16201620
return completionAfterEdit.items.map(\.label) == ["self", "test()"]
16211621
}
16221622
}
1623+
1624+
func testSymlinkedTargetReferringToSameSourceFile() async throws {
1625+
let project = try await SwiftPMTestProject(
1626+
files: [
1627+
"LibA/LibA.swift": """
1628+
public let myVar: String
1629+
""",
1630+
"Client/Client.swift": """
1631+
import LibASymlink
1632+
1633+
func test() {
1634+
print(1️⃣myVar)
1635+
}
1636+
""",
1637+
],
1638+
manifest: """
1639+
let package = Package(
1640+
name: "MyLibrary",
1641+
targets: [
1642+
.target(name: "LibA"),
1643+
.target(name: "LibASymlink"),
1644+
.target(name: "Client", dependencies: ["LibASymlink"]),
1645+
]
1646+
)
1647+
""",
1648+
workspaces: { scratchDirectory in
1649+
let sources = scratchDirectory.appendingPathComponent("Sources")
1650+
try FileManager.default.createSymbolicLink(
1651+
at: sources.appendingPathComponent("LibASymlink"),
1652+
withDestinationURL: sources.appendingPathComponent("LibA")
1653+
)
1654+
return [WorkspaceFolder(uri: DocumentURI(scratchDirectory))]
1655+
},
1656+
enableBackgroundIndexing: true
1657+
)
1658+
1659+
let (uri, positions) = try project.openDocument("Client.swift")
1660+
let preEditHover = try await project.testClient.send(
1661+
HoverRequest(textDocument: TextDocumentIdentifier(uri), position: positions["1️⃣"])
1662+
)
1663+
let preEditHoverContent = try XCTUnwrap(preEditHover?.contents.markupContent?.value)
1664+
XCTAssert(
1665+
preEditHoverContent.contains("String"),
1666+
"Pre edit hover content '\(preEditHoverContent)' does not contain 'String'"
1667+
)
1668+
1669+
let libAUri = try project.uri(for: "LibA.swift")
1670+
try "public let myVar: Int".write(to: try XCTUnwrap(libAUri.fileURL), atomically: true, encoding: .utf8)
1671+
project.testClient.send(DidChangeWatchedFilesNotification(changes: [FileEvent(uri: libAUri, type: .changed)]))
1672+
1673+
try await repeatUntilExpectedResult {
1674+
let postEditHover = try await project.testClient.send(
1675+
HoverRequest(textDocument: TextDocumentIdentifier(uri), position: positions["1️⃣"])
1676+
)
1677+
return try XCTUnwrap(postEditHover?.contents.markupContent?.value).contains("Int")
1678+
}
1679+
}
16231680
}
16241681

16251682
extension HoverResponseContents {

0 commit comments

Comments
 (0)