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 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
47 changes: 43 additions & 4 deletions Sources/SourceKitLSP/SourceKitLSPServer.swift
Original file line number Diff line number Diff line change
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,34 @@ 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 case .generatedInterface(let interfaceData) = try? ReferenceDocumentURL(from: originatorUri),
Copy link
Member

Choose a reason for hiding this comment

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

This comment still applies here: #2233

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

guard
let interfaceDetails = try await languageService.openGeneratedInterface(
document: originatorUri,
document: documentForBuildSettings,
moduleName: moduleName,
groupName: groupName,
symbolUSR: symbolUSR
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,14 @@ package struct GeneratedInterfaceDocumentURLData: Hashable, ReferenceURLData {
self.moduleName = moduleName
self.groupName = groupName
self.sourcekitdDocumentName = sourcekitdDocumentName
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
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

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

}

init(queryItems: [URLQueryItem]) throws {
Expand Down
19 changes: 13 additions & 6 deletions Sources/SourceKitLSP/Swift/ReferenceDocumentURL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,14 +122,25 @@ package enum ReferenceDocumentURL {
var buildSettingsFile: DocumentURI {
switch self {
case .macroExpansion(let data): return data.primaryFile
case .generatedInterface(let data): return data.buildSettingsFrom
case .generatedInterface(let data):
if let referenceDocumentURL = try? ReferenceDocumentURL(from: data.buildSettingsFrom),
let fileURL = referenceDocumentURL.buildSettingsFile.fileURL {
return DocumentURI(fileURL)
}
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),
let primaryFile = referenceDocumentURL.primaryFile,
let fileURL = primaryFile.fileURL {
return DocumentURI(fileURL)
}
return data.buildSettingsFrom.primaryFile
}
}
}
Expand Down Expand Up @@ -170,8 +181,4 @@ extension DocumentURI {

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

init(description: String) {
self.description = description
}
}
194 changes: 156 additions & 38 deletions Tests/SourceKitLSPTests/SwiftInterfaceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import SourceKitLSP
import SwiftExtensions
import XCTest

private extension Language {
static let swiftinterface = Language(rawValue: "swiftinterface")
}

final class SwiftInterfaceTests: XCTestCase {
func testSystemModuleInterface() async throws {
let testClient = try await TestSourceKitLSPClient()
Expand Down Expand Up @@ -62,7 +66,7 @@ final class SwiftInterfaceTests: XCTestCase {
let referenceDocument = try await testClient.send(GetReferenceDocumentRequest(uri: location.uri))
XCTAssert(
referenceDocument.content.hasPrefix("import "),
"Expected that the foundation swift interface starts with 'import ' but got '\(referenceDocument.content.prefix(100))'"
"Expected foundation swift interface to start with 'import ' but got '\(referenceDocument.content.prefix(100))'"
)
}

Expand Down Expand Up @@ -149,7 +153,7 @@ final class SwiftInterfaceTests: XCTestCase {
public init() {}
}
""",
"Exec/main.swift": "import 1️⃣MyLibrary",
"Exec/main.swift": "import 1️⃣MyLibrary"
],
manifest: """
let package = Package(
Expand Down Expand Up @@ -192,21 +196,13 @@ final class SwiftInterfaceTests: XCTestCase {
func testSemanticFunctionalityInGeneratedInterface() async throws {
let project = try await SwiftPMTestProject(
files: [
"MyLibrary/MyLibrary.swift": """
public struct Lib {
public func foo() -> String {}
public init() {}
}
""",
"Exec/main.swift": "import 1️⃣MyLibrary",
"MyLibrary/MyLibrary.swift": "public struct Lib { public func foo() -> String {} }",
"Exec/main.swift": "import 1️⃣MyLibrary"
],
manifest: """
let package = Package(
name: "MyLibrary",
targets: [
.target(name: "MyLibrary"),
.executableTarget(name: "Exec", dependencies: ["MyLibrary"])
]
targets: [.target(name: "MyLibrary"), .executableTarget(name: "Exec", dependencies: ["MyLibrary"])]
)
""",
capabilities: ClientCapabilities(experimental: [
Expand All @@ -216,36 +212,26 @@ final class SwiftInterfaceTests: XCTestCase {
)

let (mainUri, mainPositions) = try project.openDocument("main.swift")
let response =
try await project.testClient.send(
DefinitionRequest(
textDocument: TextDocumentIdentifier(mainUri),
position: mainPositions["1️⃣"]
)
)
let response = try await project.testClient.send(
DefinitionRequest(textDocument: TextDocumentIdentifier(mainUri), position: mainPositions["1️⃣"])
)
let referenceDocumentUri = try XCTUnwrap(response?.locations?.only).uri
let referenceDocument = try await project.testClient.send(GetReferenceDocumentRequest(uri: referenceDocumentUri))
let stringIndex = try XCTUnwrap(referenceDocument.content.firstRange(of: "-> String"))
let (stringLine, stringColumn) = LineTable(referenceDocument.content)
.lineAndUTF16ColumnOf(referenceDocument.content.index(stringIndex.lowerBound, offsetBy: 3))

project.testClient.send(
DidOpenTextDocumentNotification(
textDocument: TextDocumentItem(
uri: referenceDocumentUri,
language: .swift,
version: 0,
text: referenceDocument.content

// Test hover functionality in the interface
if let stringIndex = referenceDocument.content.firstRange(of: "-> String") {
let (line, column) = LineTable(referenceDocument.content)
.lineAndUTF16ColumnOf(referenceDocument.content.index(stringIndex.lowerBound, offsetBy: 3))
project.testClient.send(
DidOpenTextDocumentNotification(
textDocument: TextDocumentItem(uri: referenceDocumentUri, language: .swift, version: 0, text: referenceDocument.content)
)
)
)
let hover = try await project.testClient.send(
HoverRequest(
textDocument: TextDocumentIdentifier(referenceDocumentUri),
position: Position(line: stringLine, utf16index: stringColumn)
let hover = try await project.testClient.send(
HoverRequest(textDocument: TextDocumentIdentifier(referenceDocumentUri), position: Position(line: line, utf16index: column))
)
)
XCTAssertNotNil(hover)
XCTAssertNotNil(hover)
}
}

func testJumpToSynthesizedExtensionMethodInSystemModuleWithoutIndex() async throws {
Expand Down Expand Up @@ -295,6 +281,7 @@ final class SwiftInterfaceTests: XCTestCase {
GetReferenceDocumentRequest.method: .dictionary(["supported": .bool(true)])
])
)

let uri = DocumentURI(for: .swift)

let positions = testClient.openDocument(
Expand All @@ -319,6 +306,137 @@ final class SwiftInterfaceTests: XCTestCase {
)
XCTAssertEqual(diagnostics.fullReport?.items, [])
}

func testInternalNavigationInSwiftInterface() async throws {
let testClient = try await TestSourceKitLSPClient(
capabilities: ClientCapabilities(experimental: [
GetReferenceDocumentRequest.method: .dictionary(["supported": .bool(true)])
])
)
let uri = DocumentURI(for: .swiftinterface)
Copy link
Member

Choose a reason for hiding this comment

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

The original source file is a plain .swift file, not a Swift interface, so this should be for: .swift. Also, I don’t think you need the Language definition for .swiftinterface at the top of this file.

Copy link
Author

@aelam aelam Aug 13, 2025

Choose a reason for hiding this comment

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

I assumed user is already in interface file
since it makes no difference I can remove it

 XCTAssertTrue(
        location.uri.pseudoPath.hasSuffix(".swiftinterface") ||

I added test case for file suffix, is it necessary?


let positions = testClient.openDocument(
"""
func test(x: 1️⃣String) {
let a: 2️⃣Int = 5
}
""",
uri: uri
)

// First, get definition for String to open the Swift interface
let stringDefinition = try await testClient.send(
DefinitionRequest(textDocument: TextDocumentIdentifier(uri), position: positions["1️⃣"])
)
let interfaceUri = try XCTUnwrap(stringDefinition?.locations?.only?.uri)
let interfaceContents = try await testClient.send(GetReferenceDocumentRequest(uri: interfaceUri))
// Open the interface document
testClient.send(
DidOpenTextDocumentNotification(
textDocument: TextDocumentItem(
uri: interfaceUri,
language: .swift,
version: 0,
text: interfaceContents.content
)
)
)

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

XCTFail("Could not find 'public init()' in String interface")
return
}
let lineTable = LineTable(interfaceContents.content)
let (line, column) = lineTable.lineAndUTF16ColumnOf(initRange.lowerBound)
let initPosition = Position(line: line, utf16index: column + 7) // Position on "init"

// Test definition request within the interface
let internalDefinition = try await testClient.send(
DefinitionRequest(
textDocument: TextDocumentIdentifier(interfaceUri),
position: initPosition
)
)

// The definition should either:
// 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?

if let location = internalDefinition?.locations?.first {
// If we get a location, it should be in the same interface or a related one
XCTAssertTrue(
location.uri.pseudoPath.hasSuffix(".swiftinterface") ||
location.uri == interfaceUri,
"Internal navigation should stay within interface files, got: \(location.uri.pseudoPath)"
)
}
}

func testFoundationImportNavigation() async throws {
let testClient = try await TestSourceKitLSPClient(
capabilities: ClientCapabilities(experimental: [
GetReferenceDocumentRequest.method: .dictionary(["supported": .bool(true)])
])
)
let uri = DocumentURI(for: .swiftinterface)

let positions = testClient.openDocument(
"""
import 1️⃣Foundation
""",
uri: uri
)

// Test navigation to Foundation module
let foundationDefinition = try await testClient.send(
DefinitionRequest(textDocument: TextDocumentIdentifier(uri), position: positions["1️⃣"])
)
let foundationLocation = try XCTUnwrap(foundationDefinition?.locations?.only)
// Verify it's a swiftinterface file (can be either file:// or sourcekit-lsp:// scheme)
XCTAssertTrue(
foundationLocation.uri.pseudoPath.hasSuffix(".swiftinterface") ||
(foundationLocation.uri.scheme == "sourcekit-lsp" &&
foundationLocation.uri.pseudoPath.contains("Foundation.swiftinterface"))
)
}

func testFoundationSubmoduleNavigation() async throws {
let testClient = try await TestSourceKitLSPClient(
capabilities: ClientCapabilities(experimental: [
GetReferenceDocumentRequest.method: .dictionary(["supported": .bool(true)])
])
)
let uri = DocumentURI(for: .swift)

let positions = testClient.openDocument(
"""
import 1️⃣Foundation.2️⃣NSAffineTransform
""",
uri: uri
)

let foundationDefinition = try await testClient.send(
DefinitionRequest(textDocument: TextDocumentIdentifier(uri), position: positions["1️⃣"])
)
if let foundationLocation = foundationDefinition?.locations?.only {
XCTAssertTrue(
foundationLocation.uri.pseudoPath.contains("Foundation.swiftinterface") ||
foundationLocation.uri.scheme == "sourcekit-lsp"
)
}
// Test navigation to NSAffineTransform
let transformDefinition = try await testClient.send(
DefinitionRequest(textDocument: TextDocumentIdentifier(uri), position: positions["2️⃣"])
)
if let transformLocation = transformDefinition?.locations?.only {
// Verify we can identify this as a swiftinterface file
XCTAssertTrue(
transformLocation.uri.pseudoPath.contains("Foundation.NSAffineTransform.swiftinterface") ||
transformLocation.uri.scheme == "sourcekit-lsp"
)
}
}
}

private func assertSystemSwiftInterface(
Expand Down