Skip to content

Commit 015d626

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 015d626

14 files changed

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

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

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

71214
var completeExpansionFileContent = ""
72215
var completeExpansionDirectoryName = ""
73216

74217
var macroExpansionReferenceDocumentURLs: [ReferenceDocumentURL] = []
75-
for macroEdit in expansion.edits {
218+
for macroEdit in expansions {
76219
if let bufferName = macroEdit.bufferName {
77220
let macroExpansionReferenceDocumentURLData =
78221
ReferenceDocumentURL.macroExpansion(
79222
MacroExpansionReferenceDocumentURLData(
80223
macroExpansionEditRange: macroEdit.range,
81-
primaryFileURL: primaryFileURL,
224+
parent: expandMacroCommand.textDocument.uri,
82225
selectionRange: expandMacroCommand.positionRange,
83226
bufferName: bufferName
84227
)
@@ -90,7 +233,7 @@ extension SwiftLanguageService {
90233

91234
let editContent =
92235
"""
93-
// \(primaryFileURL.lastPathComponent) @ \(macroEdit.range.lowerBound.line + 1):\(macroEdit.range.lowerBound.utf16index + 1) - \(macroEdit.range.upperBound.line + 1):\(macroEdit.range.upperBound.utf16index + 1)
236+
// \(primaryFileDisplayName) @ \(macroEdit.range.lowerBound.line + 1):\(macroEdit.range.lowerBound.utf16index + 1) - \(macroEdit.range.upperBound.line + 1):\(macroEdit.range.upperBound.utf16index + 1)
94237
\(macroEdit.newText)
95238
96239
@@ -154,7 +297,7 @@ extension SwiftLanguageService {
154297
}
155298

156299
completeExpansionFilePath =
157-
completeExpansionFilePath.appendingPathComponent(primaryFileURL.lastPathComponent)
300+
completeExpansionFilePath.appendingPathComponent(primaryFileDisplayName)
158301
do {
159302
try completeExpansionFileContent.write(to: completeExpansionFilePath, atomically: true, encoding: .utf8)
160303
} catch {
@@ -178,23 +321,4 @@ extension SwiftLanguageService {
178321
}
179322
}
180323
}
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-
}
200324
}

0 commit comments

Comments
 (0)