Skip to content

Fix module definition jumping in Swift interface files #2233

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ extension Language {
case "cpp", "cc", "cxx", "hpp": self = .cpp
case "m": self = .objective_c
case "mm", "h": self = .objective_cpp
case "swift": self = .swift
case "swift", "swiftinterface": self = .swift
default: return nil
}
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/SKTestSupport/Utils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ extension Language {
case "cpp": self = .cpp
case "m": self = .objective_c
case "mm": self = .objective_cpp
case "swift": self = .swift
case "swift", "swiftinterface": self = .swift
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to my other comment about the .swiftinterface extension, I don’t think we need this here, do we? This only applies to tests and I don’t think we open any .swiftinterface files in tests.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I deleted but please check the tests about swiftinterface

case "md": self = .markdown
case "tutorial": self = .tutorial
default: return nil
Expand Down
51 changes: 46 additions & 5 deletions Sources/SourceKitLSP/SourceKitLSPServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,7 @@ package actor SourceKitLSPServer {
}

let toolchain = await workspace.buildServerManager.toolchain(
for: uri,
for: uri.buildSettingsFile,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this change necessary? I just checked and it looks like toolchain(for:in:language:) doesn’t even use uri right now.

in: workspace.buildServerManager.canonicalTarget(for: uri),
language: language
)
Expand Down Expand Up @@ -1850,10 +1850,24 @@ extension SourceKitLSPServer {
languageService: LanguageService
) async throws -> [Location] {
// If this symbol is a module then generate a textual interface
if symbol.kind == .module, let name = symbol.name {
if symbol.kind == .module {
// For module symbols, prefer using systemModule information if available
let moduleName: String
let groupName: String?

if let systemModule = symbol.systemModule {
moduleName = systemModule.moduleName
groupName = systemModule.groupName
} else if let name = symbol.name {
moduleName = name
groupName = nil
} else {
return []
}

let interfaceLocation = try await self.definitionInInterface(
moduleName: name,
groupName: nil,
moduleName: moduleName,
groupName: groupName,
symbolUSR: nil,
originatorUri: uri,
languageService: languageService
Expand Down Expand Up @@ -2051,9 +2065,36 @@ extension SourceKitLSPServer {
originatorUri: DocumentURI,
languageService: LanguageService
) async throws -> Location {
// Check if we're already in the target interface with the same module/group/symbol
if let referenceDoc = try? ReferenceDocumentURL(from: originatorUri),
case .generatedInterface(let interfaceData) = referenceDoc,
interfaceData.moduleName == moduleName && interfaceData.groupName == groupName {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly to my other comments about the build settings, we need to check here that we are using build settings from the same original file. But I think as long as we pass along the same build settings file, this shouldn’t cause any issues.

Copy link
Author

@aelam aelam Aug 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ahoppen Thanks for the comment

without this change, here would happen

if I open defination of Foundation in Foundation.swiftInterace, it will open another one, repeat, it will open another new one
even if it's acceptable but it's quite slow (please check the video)

Cause:

originalUri is sourcekit-lsp://xxx?buildSettingsFrom=A
A is sourcekit-lsp://xxx?buildSettingsFrom=B
B is sourcekit-lsp://xxx?buildSettingsFrom=C
C is file://

this makes the logic quite complicated

  1. we have to recursively find the buildSettingsFrom
  2. originalUri are different for same swiftinterface

I think we could assume the interface is the same if the interface was originally opened from a same buildSettingsFrom
because interface opened from A, B should always be consistent?

then we could simplify the originalUri as

originalUri is sourcekit-lsp://xxx?buildSettingsFrom=C

the benefits are

  1. we don't need to resursively search
  2. caching/creating of swiftinterface could be simplified and easy to hit the cache
  3. much faster
  4. the code change here is not needed any more.
Untitled.mov

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, IMO when doing a jump to definition from within an interface, the buildSettingsFrom should be the original source file rather than the interface you're currently in. IIUC this is what you're saying in:

then we could simplify the originalUri as
originalUri is sourcekit-lsp://xxx?buildSettingsFrom=C
? If so, then 👍


// If we have a specific symbol USR, try to find its position in the current interface
if let symbolUSR = symbolUSR,
let swiftLanguageService = languageService as? SwiftLanguageService {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m currently trying to unravel the dependency of the SourceKitLSP module on SwiftLanguageService in #2234, so getting the SwiftLanguageService won’t work in the future.

What are the challenges of always going through openGeneratedInterface? I can see the following but I might be missing some:

  • Every time openGeneratedInterface is called, we get a new sourcekitdDocumentName. We could fix that by instead of appending the group name and a hash of document (or something of that sort).
  • If the issue is that we always write a new file if the client doesn’t support reference documents, we could just not write out the file if the contents haven’t changed

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

adding a group might be a good idea

i imagine there could be different versions of swiftinterface which are used by different modules/files

i think the challenge is how to determine it in the beginning instead of a uuid
then the cache logic could be simplified

do {
let position = try await swiftLanguageService.generatedInterfaceManager.position(
ofUsr: symbolUSR,
in: interfaceData
)
return Location(uri: originatorUri, range: Range(position))
} catch {
// If we can't find the symbol, just return the top of the current interface
return Location(uri: originatorUri, range: Range(Position(line: 0, utf16index: 0)))
}
} else {
// No specific symbol, just return the current interface location
return Location(uri: originatorUri, range: Range(Position(line: 0, utf16index: 0)))
}
}

// If the originator URI is already a generated interface, use its primary file for build settings
let documentForBuildSettings = originatorUri.primaryFile ?? originatorUri

guard
let interfaceDetails = try await languageService.openGeneratedInterface(
document: originatorUri,
document: documentForBuildSettings,
moduleName: moduleName,
groupName: groupName,
symbolUSR: symbolUSR
Expand Down
14 changes: 12 additions & 2 deletions Sources/SourceKitLSP/Swift/GeneratedInterfaceManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,13 @@ actor GeneratedInterfaceManager {
incrementingRefCount: Bool
) async throws -> OpenGeneratedInterfaceDocumentDetails {
func loadFromCache() -> OpenGeneratedInterfaceDocumentDetails? {
guard let cachedIndex = openInterfaces.firstIndex(where: { $0.url == document }) else {
// Cache by module name and group name, not the full document data
// This allows reuse across different buildSettingsFrom URIs
guard let cachedIndex = openInterfaces.firstIndex(where: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don’t think this is correct. Depending on the build settings used to retrieve the generated interface, the interface might look differently. I can’t come up with a great real-world example right now where the contents differ but a canonical example would be that we can’t load the generated interface if we are missing an include path to one of the module’s dependencies but we can generate the interface if all build settings are present.

But if we continue passing along the same buildSettingsFrom file, I don’t think we need this change. Or am I missing something?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you are right

but about same buildSettingsFrom file please check here #2233 (comment)

$0.url.moduleName == document.moduleName &&
$0.url.groupName == document.groupName &&
$0.url.sourcekitdDocumentName == document.sourcekitdDocumentName
}) else {
return nil
}
if incrementingRefCount {
Expand Down Expand Up @@ -160,7 +166,11 @@ actor GeneratedInterfaceManager {
}

private func decrementRefCount(for document: GeneratedInterfaceDocumentURLData) {
guard let cachedIndex = openInterfaces.firstIndex(where: { $0.url == document }) else {
guard let cachedIndex = openInterfaces.firstIndex(where: {
$0.url.moduleName == document.moduleName &&
$0.url.groupName == document.groupName &&
$0.url.sourcekitdDocumentName == document.sourcekitdDocumentName
}) else {
logger.fault(
"Generated interface document for \(document.moduleName) is not open anymore. Unbalanced retain and releases?"
)
Expand Down
10 changes: 9 additions & 1 deletion Sources/SourceKitLSP/Swift/OpenInterface.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,18 @@ extension SwiftLanguageService {
groupName: String?,
symbolUSR symbol: String?
) async throws -> GeneratedInterfaceDetails? {
// Generate a deterministic document name based on module name and group name
// This ensures we reuse the same interface for the same module/group combination
let documentName = if let groupName {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to my comment above, generated interfaces might look different depending on the build settings that were used to generate them, so module name and group name don’t uniquely identify its contents. That’s why we used the UUID-based filename below.

"\(moduleName)-\(groupName.replacingOccurrences(of: "/", with: "-"))"
} else {
moduleName
}

let urlData = GeneratedInterfaceDocumentURLData(
moduleName: moduleName,
groupName: groupName,
sourcekitdDocumentName: "\(moduleName)-\(UUID())",
sourcekitdDocumentName: documentName,
primaryFile: document
)
let position: Position? =
Expand Down
9 changes: 5 additions & 4 deletions Sources/SourceKitLSP/Swift/ReferenceDocumentURL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,13 +165,14 @@ extension DocumentURI {
return referenceDocument.buildSettingsFile
}
return self
} /// Convert sourcekit-lsp:// URIs to actual file system paths when possible.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing newlines before ///?

/// For generated Swift interfaces, this returns the path where the interface file
/// would be stored in the GeneratedInterfaces directory.
var actualFileSystemPath: DocumentURI {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don’t think this is used anywhere, or am I missing something?

return self
}
}

package struct ReferenceDocumentURLError: Error, CustomStringConvertible {
package var description: String

init(description: String) {
self.description = description
}
}
5 changes: 3 additions & 2 deletions Sources/SourceKitLSP/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -432,11 +432,12 @@ package final class Workspace: Sendable, BuildServerManagerDelegate {
/// document is returned.
func setDocumentService(for uri: DocumentURI, _ newLanguageService: any LanguageService) -> LanguageService {
return documentService.withLock { service in
if let languageService = service[uri] {
let key = uri.buildSettingsFile
if let languageService = service[key] {
return languageService
}

service[uri] = newLanguageService
service[key] = newLanguageService
return newLanguageService
}
}
Expand Down