Skip to content

Commit 90e0f3f

Browse files
ahoppenlokesh-tr
andcommitted
Support expansion of nested macros
The basic idea is that a `sourcekit-lsp://swift-macro-expansion` URL should have sufficient information to reconstruct the contents of that macro buffer without relying on any state in SourceKit-LSP. The benefit of not having any cross-request state in SourceKit-LSP is that an editor might can send the `workspace/getReferenceDocument` request at any time and it will succeed independent of the previous requests. Furthermore, we can always get the contents of the macro expansion to form a `DocumentSnapshot`, which can be used to provide semantic functionality inside macro expansion buffers. To do that, the `sourcekit-lsp:` URL scheme was changed to have a parent instead of a `primary`, which is the URI of the document that the buffer was expanded from. For nested macro expansions, this will be a `sourcekit-lsp://swift-macro-expansion` URL itself. With that parent, we can reconstruct the macro expansion chain all the way from the primary source file. To avoid sending the same expand macro request to sourcekitd all the time, we introduce `MacroExpansionManager`, which caches the last 10 macro expansions. `SwiftLanguageService` now has a `latestSnapshot` method that returns the contents of the reference document when asked for a reference document URL and only consults the document manager for other URIs. To support semantic functionality in macro expansion buffers, we need to call that `latestSnapshot` method so we have a document snapshot of the macro expansion buffer for position conversions and pass the following to the sourcekitd requests. ``` keys.sourceFile: snapshot.uri.sourcekitdSourceFile, keys.primaryFile: snapshot.uri.primaryFile?.pseudoPath, ``` We should consider if there’s a way to make the `latestSnapshot` method on `documentManager` less accessible so that the method which also returns snapshots for reference documents is the one being used by default. Co-Authored-By: Lokesh T R <[email protected]>
1 parent a3bb2d7 commit 90e0f3f

14 files changed

+924
-492
lines changed

Sources/SourceKitLSP/CMakeLists.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ target_sources(SourceKitLSP PRIVATE
5151
Swift/OpenInterface.swift
5252
Swift/RefactoringResponse.swift
5353
Swift/RefactoringEdit.swift
54-
Swift/RefactorCommand.swift
5554
Swift/ReferenceDocumentURL.swift
5655
Swift/RelatedIdentifiers.swift
5756
Swift/RewriteSourceKitPlaceholders.swift

Sources/SourceKitLSP/MessageHandlingDependencyTracker.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,8 @@ enum MessageHandlingDependencyTracker: DependencyTracker {
179179
} else {
180180
self = .freestanding
181181
}
182+
case let request as GetReferenceDocumentRequest:
183+
self = .documentRequest(request.uri)
182184
case is InitializeRequest:
183185
self = .globalConfigurationChange
184186
case is InlayHintRefreshRequest:

Sources/SourceKitLSP/SourceKitLSPServer.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ package actor SourceKitLSPServer {
290290
}
291291

292292
package func workspaceForDocument(uri: DocumentURI) async -> Workspace? {
293+
let uri = uri.primaryFile ?? uri
293294
if let cachedWorkspace = self.uriToWorkspaceCache[uri]?.value {
294295
return cachedWorkspace
295296
}
@@ -1693,8 +1694,7 @@ extension SourceKitLSPServer {
16931694
}
16941695

16951696
func getReferenceDocument(_ req: GetReferenceDocumentRequest) async throws -> GetReferenceDocumentResponse {
1696-
let referenceDocumentURL = try ReferenceDocumentURL(from: req.uri)
1697-
let primaryFileURI = referenceDocumentURL.primaryFile
1697+
let primaryFileURI = try ReferenceDocumentURL(from: req.uri).primaryFile
16981698

16991699
guard let workspace = await workspaceForDocument(uri: primaryFileURI) else {
17001700
throw ResponseError.workspaceNotOpen(primaryFileURI)

Sources/SourceKitLSP/Swift/ExpandMacroCommand.swift

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,7 @@
1313
import LanguageServerProtocol
1414
import SourceKitD
1515

16-
package struct ExpandMacroCommand: RefactorCommand {
17-
typealias Response = MacroExpansion
18-
16+
package struct ExpandMacroCommand: SwiftCommand {
1917
package static let identifier: String = "expand.macro.command"
2018

2119
/// The name of this refactoring action.

Sources/SourceKitLSP/Swift/MacroExpansion.swift

Lines changed: 152 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import Crypto
1414
import Foundation
1515
import LanguageServerProtocol
1616
import SKLogging
17+
import SKOptions
18+
import SKSupport
1719
import SourceKitD
1820

1921
/// Detailed information about the result of a macro expansion operation.
@@ -44,6 +46,141 @@ struct MacroExpansion: RefactoringResponse {
4446
}
4547
}
4648

49+
/// Caches the contents of macro expansions that were recently requested by the user.
50+
actor MacroExpansionManager {
51+
private struct CacheEntry {
52+
// Key
53+
let snapshotID: DocumentSnapshot.ID
54+
let range: Range<Position>
55+
let buildSettings: SwiftCompileCommand?
56+
57+
// Value
58+
let value: [RefactoringEdit]
59+
60+
fileprivate init(
61+
snapshot: DocumentSnapshot,
62+
range: Range<Position>,
63+
buildSettings: SwiftCompileCommand?,
64+
value: [RefactoringEdit]
65+
) {
66+
self.snapshotID = snapshot.id
67+
self.range = range
68+
self.buildSettings = buildSettings
69+
self.value = value
70+
}
71+
}
72+
73+
init(swiftLanguageService: SwiftLanguageService?) {
74+
self.swiftLanguageService = swiftLanguageService
75+
}
76+
77+
private weak var swiftLanguageService: SwiftLanguageService?
78+
79+
/// The number of macro expansions to cache.
80+
///
81+
/// - Note: This should be bigger than the maximum expansion depth of macros a user might do to avoid re-generating
82+
/// all parent macros to a nested macro expansion's buffer. 10 seems to be big enough for that because it's
83+
/// unlikely that a macro will expand to more than 10 levels.
84+
private let cacheSize = 10
85+
86+
/// The cache that stores reportTasks for a combination of uri, range and build settings.
87+
///
88+
/// Conceptually, this is a dictionary. To prevent excessive memory usage we
89+
/// only keep `cacheSize` entries within the array. Older entries are at the
90+
/// end of the list, newer entries at the front.
91+
private var cache: [CacheEntry] = []
92+
93+
/// Return the text of the macro expansion referenced by `macroExpansionURLData`.
94+
func macroExpansion(
95+
for macroExpansionURLData: MacroExpansionReferenceDocumentURLData
96+
) async throws -> String {
97+
let expansions = try await macroExpansions(
98+
in: macroExpansionURLData.parent,
99+
at: macroExpansionURLData.selectionRange
100+
)
101+
guard let expansion = expansions.filter({ $0.bufferName == macroExpansionURLData.bufferName }).only else {
102+
throw ResponseError.unknown("Failed to find macro expansion for \(macroExpansionURLData.bufferName).")
103+
}
104+
return expansion.newText
105+
}
106+
107+
func macroExpansions(
108+
in uri: DocumentURI,
109+
at range: Range<Position>
110+
) async throws -> [RefactoringEdit] {
111+
guard let swiftLanguageService else {
112+
// `SwiftLanguageService` has been destructed. We are tearing down the language server. Nothing left to do.
113+
throw ResponseError.unknown("Connection to the editor closed")
114+
}
115+
116+
let snapshot = try await swiftLanguageService.latestSnapshot(for: uri)
117+
let buildSettings = await swiftLanguageService.buildSettings(for: uri)
118+
119+
if let cacheEntry = cache.first(where: {
120+
$0.snapshotID == snapshot.id && $0.range == range && $0.buildSettings == buildSettings
121+
}) {
122+
return cacheEntry.value
123+
}
124+
let macroExpansions = try await macroExpansionsImpl(in: snapshot, at: range, buildSettings: buildSettings)
125+
cache.insert(
126+
CacheEntry(snapshot: snapshot, range: range, buildSettings: buildSettings, value: macroExpansions),
127+
at: 0
128+
)
129+
130+
while cache.count > cacheSize {
131+
cache.removeLast()
132+
}
133+
134+
return macroExpansions
135+
}
136+
137+
private func macroExpansionsImpl(
138+
in snapshot: DocumentSnapshot,
139+
at range: Range<Position>,
140+
buildSettings: SwiftCompileCommand?
141+
) async throws -> [RefactoringEdit] {
142+
guard let swiftLanguageService else {
143+
// `SwiftLanguageService` has been destructed. We are tearing down the language server. Nothing left to do.
144+
throw ResponseError.unknown("Connection to the editor closed")
145+
}
146+
let keys = swiftLanguageService.keys
147+
148+
let line = range.lowerBound.line
149+
let utf16Column = range.lowerBound.utf16index
150+
let utf8Column = snapshot.lineTable.utf8ColumnAt(line: line, utf16Column: utf16Column)
151+
let length = snapshot.utf8OffsetRange(of: range).count
152+
153+
let skreq = swiftLanguageService.sourcekitd.dictionary([
154+
keys.request: swiftLanguageService.requests.semanticRefactoring,
155+
// Preferred name for e.g. an extracted variable.
156+
// Empty string means sourcekitd chooses a name automatically.
157+
keys.name: "",
158+
keys.sourceFile: snapshot.uri.sourcekitdSourceFile,
159+
keys.primaryFile: snapshot.uri.primaryFile?.pseudoPath,
160+
// LSP is zero based, but this request is 1 based.
161+
keys.line: line + 1,
162+
keys.column: utf8Column + 1,
163+
keys.length: length,
164+
keys.actionUID: swiftLanguageService.sourcekitd.api.uid_get_from_cstr("source.refactoring.kind.expand.macro")!,
165+
keys.compilerArgs: buildSettings?.compilerArgs as [SKDRequestValue]?,
166+
])
167+
168+
let dict = try await swiftLanguageService.sendSourcekitdRequest(
169+
skreq,
170+
fileContents: snapshot.text
171+
)
172+
guard let expansions = [RefactoringEdit](dict, snapshot, keys) else {
173+
throw SemanticRefactoringError.noEditsNeeded(snapshot.uri)
174+
}
175+
return expansions
176+
}
177+
178+
/// Remove all cached macro expansions for the given primary file, eg. because the macro's plugin might have changed.
179+
func purge(primaryFile: DocumentURI) {
180+
cache.removeAll { $0.snapshotID.uri.primaryFile ?? $0.snapshotID.uri == primaryFile }
181+
}
182+
}
183+
47184
extension SwiftLanguageService {
48185
/// Handles the `ExpandMacroCommand`.
49186
///
@@ -62,23 +199,30 @@ extension SwiftLanguageService {
62199
throw ResponseError.unknown("Connection to the editor closed")
63200
}
64201

65-
guard let primaryFileURL = expandMacroCommand.textDocument.uri.fileURL else {
66-
throw ResponseError.unknown("Given URI is not a file URL")
67-
}
202+
let primaryFileDisplayName =
203+
switch try? ReferenceDocumentURL(from: expandMacroCommand.textDocument.uri) {
204+
case .macroExpansion(let data):
205+
data.bufferName
206+
case nil:
207+
expandMacroCommand.textDocument.uri.fileURL?.lastPathComponent ?? expandMacroCommand.textDocument.uri.pseudoPath
208+
}
68209

69-
let expansion = try await self.refactoring(expandMacroCommand)
210+
let expansions = try await macroExpansionManager.macroExpansions(
211+
in: expandMacroCommand.textDocument.uri,
212+
at: expandMacroCommand.positionRange
213+
)
70214

71215
var completeExpansionFileContent = ""
72216
var completeExpansionDirectoryName = ""
73217

74218
var macroExpansionReferenceDocumentURLs: [ReferenceDocumentURL] = []
75-
for macroEdit in expansion.edits {
219+
for macroEdit in expansions {
76220
if let bufferName = macroEdit.bufferName {
77221
let macroExpansionReferenceDocumentURLData =
78222
ReferenceDocumentURL.macroExpansion(
79223
MacroExpansionReferenceDocumentURLData(
80224
macroExpansionEditRange: macroEdit.range,
81-
primaryFileURL: primaryFileURL,
225+
parent: expandMacroCommand.textDocument.uri,
82226
selectionRange: expandMacroCommand.positionRange,
83227
bufferName: bufferName
84228
)
@@ -90,7 +234,7 @@ extension SwiftLanguageService {
90234

91235
let editContent =
92236
"""
93-
// \(primaryFileURL.lastPathComponent) @ \(macroEdit.range.lowerBound.line + 1):\(macroEdit.range.lowerBound.utf16index + 1) - \(macroEdit.range.upperBound.line + 1):\(macroEdit.range.upperBound.utf16index + 1)
237+
// \(primaryFileDisplayName) @ \(macroEdit.range.lowerBound.line + 1):\(macroEdit.range.lowerBound.utf16index + 1) - \(macroEdit.range.upperBound.line + 1):\(macroEdit.range.upperBound.utf16index + 1)
94238
\(macroEdit.newText)
95239
96240
@@ -154,7 +298,7 @@ extension SwiftLanguageService {
154298
}
155299

156300
completeExpansionFilePath =
157-
completeExpansionFilePath.appendingPathComponent(primaryFileURL.lastPathComponent)
301+
completeExpansionFilePath.appendingPathComponent(primaryFileDisplayName)
158302
do {
159303
try completeExpansionFileContent.write(to: completeExpansionFilePath, atomically: true, encoding: .utf8)
160304
} catch {
@@ -178,23 +322,4 @@ extension SwiftLanguageService {
178322
}
179323
}
180324
}
181-
182-
func expandMacro(macroExpansionURLData: MacroExpansionReferenceDocumentURLData) async throws -> String {
183-
let expandMacroCommand = ExpandMacroCommand(
184-
positionRange: macroExpansionURLData.selectionRange,
185-
textDocument: TextDocumentIdentifier(macroExpansionURLData.primaryFile)
186-
)
187-
188-
let expansion = try await self.refactoring(expandMacroCommand)
189-
190-
guard
191-
let macroExpansionEdit = expansion.edits.filter({
192-
$0.bufferName == macroExpansionURLData.bufferName
193-
}).only
194-
else {
195-
throw ResponseError.unknown("Macro expansion edit doesn't exist")
196-
}
197-
198-
return macroExpansionEdit.newText
199-
}
200325
}

0 commit comments

Comments
 (0)