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

Conversation

aelam
Copy link

@aelam aelam commented Aug 11, 2025

Fix module definition jumping for submodules and Swift interface files

Problem Description

1. Submodule Import Navigation Issues

  • Foundation.NSArray: Clicking on NSArray in import Foundation.NSArray fails to jump to definition
  • Darwin Submodules: Clicking on sysdir in import Darwin.sysdir fails to jump to definition
  • Root Cause: SourceKit-LSP cannot properly handle module names containing dots (module.submodule)

2. Swift Interface File Navigation Problems

  • Generated Interface Navigation: Command+Click navigation functionality is broken within generated .swiftinterface files
  • Multiple Interface Caching: Repeatedly opening the same module interface generates multiple duplicate files
  • Language Service Mapping: Interface files cannot be properly mapped to corresponding language services

Solution

1. Enhanced Module Symbol Processing

File: Sources/SourceKitLSP/SourceKitLSPServer.swift

Module Name Parsing Logic

// Enhanced module symbol processing
if symbol.kind == .module {
  let moduleName: String
  let groupName: String?
  
  if let systemModule = symbol.systemModule {
    // Prefer using systemModule information
    moduleName = systemModule.moduleName
    groupName = systemModule.groupName
  } else if let name = symbol.name {
    // Handle module names containing dots like "Darwin.sysdir"
    if name.contains(".") {
      let components = name.split(separator: ".", maxSplits: 1)
      moduleName = String(components[0])
      groupName = components.count == 2 ? String(components[1]) : nil
    } else {
      moduleName = name
      groupName = nil
    }
  }
}

System Module Navigation

// System module navigation handling
if symbol.isSystem ?? false, let systemModule = symbol.systemModule {
  return try await self.definitionInInterface(
    moduleName: systemModule.moduleName,
    groupName: systemModule.groupName,
    symbolUSR: symbol.usr,
    originatorUri: uri,
    languageService: languageService
  )
}

2. Swift Interface File Support

Generated Interface Manager Enhancement

File: Sources/SourceKitLSP/Swift/GeneratedInterfaceManager.swift

// Improved interface file caching
private var interfaceDocuments: [InterfaceKey: GeneratedInterfaceDocument] = [:]

// Avoid duplicate generation of interface files for the same module
struct InterfaceKey: Hashable {
  let moduleName: String
  let groupName: String?
  let buildSettings: String
}

Language Service Mapping Fix

File: Sources/SourceKitLSP/Workspace.swift

// Fix language service mapping for interface files
func setDocumentService(_ service: DocumentService, for uri: DocumentURI) {
  let key = buildSettingsFile ?? uri
  documentService[key] = service
}

3. Interface File Recognition

File: Sources/SourceKitLSP/Swift/OpenInterface.swift

// Recognize .swiftinterface file extensions
private static let swiftInterfaceExtensions: Set<String> = ["swiftinterface"]

var isSwiftInterface: Bool {
  swiftInterfaceExtensions.contains(pathExtension.lowercased())
}

Test Cases

1. Foundation Submodules

import Foundation.NSArray → Correctly jumps to NSArray interface
import Foundation.NSURL → Correctly jumps to NSURL interface

2. Darwin Submodules

import Darwin.sysdir → Correctly jumps to sysdir interface
import Darwin.uuid → Correctly jumps to uuid interface
import Darwin.POSIX → Correctly jumps to POSIX interface

3. Swift Interface Navigation

✅ Command+Click navigation works properly within generated .swiftinterface files
✅ Avoids duplicate generation of interface files for the same module
✅ Symbols in interface files can correctly jump to definitions

Technical Details

Key Improvements

  1. Module Name Parsing: Properly handles module.submodule format import statements
  2. System Module Support: Uses systemModule information for accurate module navigation
  3. Interface File Caching: Avoids duplicate generation, improves performance
  4. Language Service Mapping: Ensures interface files can be properly mapped to language services
  5. File Type Recognition: Correctly identifies .swiftinterface file types

Backward Compatibility

  • ✅ Maintains normal functionality of existing import statements
  • ✅ Does not affect navigation behavior of non-system modules
  • ✅ Compatible with existing interface generation mechanisms

Impact

Feature Improvements

  1. Submodule Navigation: Full support for Foundation and Darwin submodule navigation
  2. Interface File Navigation: Swift interface file navigation functionality restored
  3. Performance: Reduced duplicate interface file generation, improved cache efficiency
  4. Stability: Fixed interface file language service mapping issues

Related Issues

  • Fix Foundation.NSArray and other submodule import jumping issues
  • Fix Darwin.sysdir and other system submodule navigation
  • Resolve navigation failures within generated Swift interface files
  • Optimize interface file caching mechanism to avoid duplicate generation

Testing Recommendations

It is recommended to test in the following scenarios:

  1. Various Foundation submodule import jumping
  2. Darwin system submodule import jumping
  3. Navigation within generated Swift interface files
  4. Caching behavior when opening the same module interface multiple times
  5. Ensure normal module imports are not affected

symbols

* fixed second swiftinterface page won't response
* fixed tap the same name of swiftinterface, it would open another swiftinterface with same content
@aelam aelam marked this pull request as draft August 11, 2025 12:57
@aelam aelam marked this pull request as ready for review August 11, 2025 13:21
@aelam
Copy link
Author

aelam commented Aug 11, 2025

@swift-ci Please test

Copy link
Member

@ahoppen ahoppen left a comment

Choose a reason for hiding this comment

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

Thanks for the PR @aelam. The screen recording looks great. 🚀 I left a few comments mostly about the subtleties between generated interfaces and build settings inline.

Could you also add a test case for the new functionality? You should be able to follow the structure in SwiftInterfaceTests.testSemanticFunctionalityInGeneratedInterface.

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)

@@ -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.

@@ -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 ///?

} /// Convert sourcekit-lsp:// URIs to actual file system paths when possible.
/// 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?

@@ -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.

// 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

Comment on lines 125 to 140
case .generatedInterface(let data):
if let referenceDocumentURL = try? ReferenceDocumentURL(from: data.buildSettingsFrom) {
return referenceDocumentURL.buildSettingsFile
}
return data.buildSettingsFrom
}
}

var primaryFile: DocumentURI? {
switch self {
case .macroExpansion(let data): return data.primaryFile
case .generatedInterface(let data): return data.buildSettingsFrom.primaryFile
case .generatedInterface(let data):
if let referenceDocumentURL = try? ReferenceDocumentURL(from: data.buildSettingsFrom) {
return referenceDocumentURL.primaryFile
}
return data.buildSettingsFrom.primaryFile
Copy link
Author

Choose a reason for hiding this comment

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

Comment on lines 70 to 77
if let referenceDocumentURL = try? ReferenceDocumentURL(from: primaryFile),
let fileURL = referenceDocumentURL.buildSettingsFile.fileURL {
self.buildSettingsFrom = DocumentURI(fileURL)
} else if let fileURL = primaryFile.fileURL {
self.buildSettingsFrom = DocumentURI(fileURL)
} else {
self.buildSettingsFrom = primaryFile
}
Copy link
Author

Choose a reason for hiding this comment

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

@ahoppen

make sure buildSettingsFrom can be only an actual file otherwise

it could be
sourcekit-lsp://xxx?buildSettingsFrom=A
A=sourcekit-lsp://xxx?buildSettingsFrom=B

NOTE: this happens if we enable navigation inside swiftinterface in this PR

@aelam aelam requested a review from ahoppen August 12, 2025 17:01
Copy link
Member

@ahoppen ahoppen left a comment

Choose a reason for hiding this comment

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

Thanks for your continued work on this PR and adding the tests, @aelam. I left some comments, mostly on the tests. Also, addressing the comments in SourceKitLSPServer.swift would be important to make the next review round meaningful.

Comment on lines 70 to 77
self.buildSettingsFrom = primaryFile
if let referenceDocumentURL = try? ReferenceDocumentURL(from: primaryFile),
let fileURL = referenceDocumentURL.buildSettingsFile.fileURL {
self.buildSettingsFrom = DocumentURI(fileURL)
} else if let fileURL = primaryFile.fileURL {
self.buildSettingsFrom = DocumentURI(fileURL)
} else {
self.buildSettingsFrom = primaryFile
}
Copy link
Member

Choose a reason for hiding this comment

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

We have an accessor on DocumentURI that should do exactly the same, so you should be able to simplify this to

Suggested change
self.buildSettingsFrom = primaryFile
if let referenceDocumentURL = try? ReferenceDocumentURL(from: primaryFile),
let fileURL = referenceDocumentURL.buildSettingsFile.fileURL {
self.buildSettingsFrom = DocumentURI(fileURL)
} else if let fileURL = primaryFile.fileURL {
self.buildSettingsFrom = DocumentURI(fileURL)
} else {
self.buildSettingsFrom = primaryFile
}
self.buildSettingsFrom = primaryFile.buildSettingsFile

@@ -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

)

// Find a symbol within the interface (e.g., "init" method in String)
guard let initRange = interfaceContents.content.range(of: "public init()") else {
Copy link
Member

Choose a reason for hiding this comment

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

Could we pick a function that’s unique within the Swift interface (eg. public func withUTF8Buffer) to make this test less likely to change if new initializers get introduced.

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 this tests, maybe it's duplicated

Comment on lines 363 to 365
// 1. Return a position within the same interface (internal navigation)
// 2. Return the same position if it's already at the definition
// 3. Return nil if no definition is available
Copy link
Member

Choose a reason for hiding this comment

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

The test should deterministically return the same results, in which case only one of these checks should be true. Could we remove the other options?

}
}

func testFoundationImportNavigation() async throws {
Copy link
Member

Choose a reason for hiding this comment

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

Is there anything in this test that isn’t already covered by testSwiftInterfaceAcrossModules?

Copy link
Author

Choose a reason for hiding this comment

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

deleted

@aelam aelam force-pushed the fix/group_of_module_definition branch from 1c667dc to 36a5f83 Compare August 13, 2025 11:53
@aelam aelam changed the title Fix module definition jumping for submodules and Swift interface files Fix module definition jumping in Swift interface files Aug 13, 2025
@aelam
Copy link
Author

aelam commented Aug 13, 2025

Also, addressing the comments in SourceKitLSPServer.swift

I went through all but maybe I missed any you may want to highlight?

Sorry about too many unrelated changes, I just reverted a lot of unneccessary change and resovle conversation to make it clean

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants