Skip to content

Commit 4086874

Browse files
committed
Implement push diagnostics in terms of DocumentDiagnosticsRequest
Instead of listening for document updates sent from `sourcekitd` and sending a `PublishDiagnosticsNotification` based on the `sourcekitd` notification, wait for a little while and then execute an internal `DocumentDiagnosticsRequest` to load diagnostics and send them to the client. This has two advantages: - It unifies the two diagnostic implementations - It removes the need to keep track of semantic diagnostics in `SwiftLanguageServer` - It gets us one step closed to opening and editing documents in `syntactic_only` mode. The only thing that is left now are semantic tokens.
1 parent ce264ad commit 4086874

15 files changed

+439
-512
lines changed

Package.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,8 +291,10 @@ let package = Package(
291291
"SKSwiftPMWorkspace",
292292
"SourceKitD",
293293
.product(name: "IndexStoreDB", package: "indexstore-db"),
294+
.product(name: "SwiftDiagnostics", package: "swift-syntax"),
294295
.product(name: "SwiftIDEUtils", package: "swift-syntax"),
295296
.product(name: "SwiftParser", package: "swift-syntax"),
297+
.product(name: "SwiftParserDiagnostics", package: "swift-syntax"),
296298
.product(name: "SwiftSyntax", package: "swift-syntax"),
297299
.product(name: "SwiftToolsSupport-auto", package: "swift-tools-support-core"),
298300
],

Sources/LSPTestSupport/Assertions.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ public func assertThrowsError<T>(
4242
_ message: @autoclosure () -> String = "",
4343
file: StaticString = #filePath,
4444
line: UInt = #line,
45-
_ errorHandler: (_ error: Error) -> Void = { _ in }
45+
errorHandler: (_ error: Error) -> Void = { _ in }
4646
) async {
4747
let didThrow: Bool
4848
do {

Sources/SKTestSupport/TestSourceKitLSPClient.swift

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import XCTest
2121

2222
extension SourceKitServer.Options {
2323
/// The default SourceKitServer options for testing.
24-
public static var testDefault = Self()
24+
public static var testDefault = Self(swiftPublishDiagnosticsDebounceDuration: 0)
2525
}
2626

2727
/// A mock SourceKit-LSP client (aka. a mock editor) that behaves like an editor
@@ -63,13 +63,13 @@ public final class TestSourceKitLSPClient: MessageHandler {
6363
/// - Parameters:
6464
/// - useGlobalModuleCache: If `false`, the server will use its own module
6565
/// cache in an empty temporary directory instead of the global module cache.
66-
public init(useGlobalModuleCache: Bool = true) {
66+
public init(serverOptions: SourceKitServer.Options = .testDefault, useGlobalModuleCache: Bool = true) {
6767
if !useGlobalModuleCache {
6868
moduleCache = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString)
6969
} else {
7070
moduleCache = nil
7171
}
72-
var serverOptions = SourceKitServer.Options.testDefault
72+
var serverOptions = serverOptions
7373
if let moduleCache {
7474
serverOptions.buildSetup.flags.swiftCompilerFlags += ["-module-cache-path", moduleCache.path]
7575
}
@@ -158,14 +158,16 @@ public final class TestSourceKitLSPClient: MessageHandler {
158158
///
159159
/// If the next notification is not a `PublishDiagnosticsNotification`, this
160160
/// methods throws.
161-
public func nextDiagnosticsNotification() async throws -> PublishDiagnosticsNotification {
161+
public func nextDiagnosticsNotification(
162+
timeout: TimeInterval = defaultTimeout
163+
) async throws -> PublishDiagnosticsNotification {
162164
struct CastError: Error, CustomStringConvertible {
163165
let actualType: any NotificationType.Type
164166

165167
var description: String { "Expected a publish diagnostics notification but got '\(actualType)'" }
166168
}
167169

168-
let nextNotification = try await nextNotification()
170+
let nextNotification = try await nextNotification(timeout: timeout)
169171
guard let diagnostics = nextNotification as? PublishDiagnosticsNotification else {
170172
throw CastError(actualType: type(of: nextNotification))
171173
}

Sources/SourceKitLSP/CMakeLists.txt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,12 @@ target_link_libraries(SourceKitLSP PUBLIC
4646
SKSwiftPMWorkspace
4747
SourceKitD
4848
SwiftSyntax::SwiftBasicFormat
49-
SwiftSyntax::SwiftParser
5049
SwiftSyntax::SwiftDiagnostics
50+
SwiftSyntax::SwiftIDEUtils
51+
SwiftSyntax::SwiftParser
52+
SwiftSyntax::SwiftParserDiagnostics
5153
SwiftSyntax::SwiftSyntax
52-
SwiftSyntax::SwiftIDEUtils)
54+
)
5355
target_link_libraries(SourceKitLSP PRIVATE
5456
$<$<NOT:$<PLATFORM_ID:Darwin>>:FoundationXML>)
5557

Sources/SourceKitLSP/SourceKitServer+Options.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
//
1111
//===----------------------------------------------------------------------===//
1212

13+
import Foundation
1314
import LanguageServerProtocol
1415
import SKCore
1516
import SKSupport
@@ -37,18 +38,27 @@ extension SourceKitServer {
3738
/// Override the default directory where generated interfaces will be stored
3839
public var generatedInterfacesPath: AbsolutePath
3940

41+
/// The time that `SwiftLanguageServer` should wait after an edit before starting to compute diagnostics and sending
42+
/// a `PublishDiagnosticsNotification`.
43+
///
44+
/// This is mostly intended for testing purposes so we don't need to wait the debouncing time to get a diagnostics
45+
/// notification when running unit tests.
46+
public var swiftPublishDiagnosticsDebounceDuration: TimeInterval
47+
4048
public init(
4149
buildSetup: BuildSetup = .default,
4250
clangdOptions: [String] = [],
4351
indexOptions: IndexOptions = .init(),
4452
completionOptions: SKCompletionOptions = .init(),
45-
generatedInterfacesPath: AbsolutePath = defaultDirectoryForGeneratedInterfaces
53+
generatedInterfacesPath: AbsolutePath = defaultDirectoryForGeneratedInterfaces,
54+
swiftPublishDiagnosticsDebounceDuration: TimeInterval = 2 /* 2s */
4655
) {
4756
self.buildSetup = buildSetup
4857
self.clangdOptions = clangdOptions
4958
self.indexOptions = indexOptions
5059
self.completionOptions = completionOptions
5160
self.generatedInterfacesPath = generatedInterfacesPath
61+
self.swiftPublishDiagnosticsDebounceDuration = swiftPublishDiagnosticsDebounceDuration
5262
}
5363
}
5464
}

Sources/SourceKitLSP/SourceKitServer.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -643,7 +643,7 @@ extension SourceKitServer: MessageHandler {
643643
}
644644

645645
private func _logResponse<Response>(_ result: LSPResult<Response>, id: RequestID, method: String) {
646-
logger.debug(
646+
logger.log(
647647
"""
648648
Sending response:
649649
Response<\(method, privacy: .public)(\(id, privacy: .public))>(

Sources/SourceKitLSP/Swift/Diagnostic.swift

Lines changed: 82 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import LSPLogging
1414
import LanguageServerProtocol
1515
import SKSupport
1616
import SourceKitD
17+
import SwiftDiagnostics
1718

1819
extension CodeAction {
1920

@@ -69,6 +70,13 @@ extension CodeAction {
6970
)
7071
}
7172

73+
init?(_ fixIt: FixIt, in snapshot: DocumentSnapshot) {
74+
// FIXME: Once https://github.com/apple/swift-syntax/pull/2226 is merged and
75+
// FixItApplier is public, use it to compute the edits that should be
76+
// applied to the source.
77+
return nil
78+
}
79+
7280
/// Describe a fixit's edit briefly.
7381
///
7482
/// For example, "Replace 'x' with 'y'", or "Remove 'z'".
@@ -161,7 +169,7 @@ extension Diagnostic {
161169
return nil
162170
}
163171

164-
var severity: DiagnosticSeverity? = nil
172+
var severity: LanguageServerProtocol.DiagnosticSeverity? = nil
165173
if let uid: sourcekitd_uid_t = diag[keys.severity] {
166174
switch uid {
167175
case values.diag_error:
@@ -234,6 +242,51 @@ extension Diagnostic {
234242
codeActions: actions
235243
)
236244
}
245+
246+
init?(
247+
_ diag: SwiftDiagnostics.Diagnostic,
248+
in snapshot: DocumentSnapshot
249+
) {
250+
guard let position = snapshot.position(of: diag.position) else {
251+
logger.error(
252+
"""
253+
Cannot construct Diagnostic from SwiftSyntax diagnostic because its UTF-8 offset \(diag.position.utf8Offset) \
254+
is out of range of the source file \(snapshot.uri.forLogging)
255+
"""
256+
)
257+
return nil
258+
}
259+
// Start with a zero-length range based on the position.
260+
// If the diagnostic has highlights associated with it that start at the
261+
// position, use that as the diagnostic's range.
262+
var range = Range(position)
263+
for highlight in diag.highlights {
264+
let swiftSyntaxRange = highlight.positionAfterSkippingLeadingTrivia..<highlight.endPositionBeforeTrailingTrivia
265+
guard let highlightRange = snapshot.range(of: swiftSyntaxRange) else {
266+
break
267+
}
268+
if range.upperBound == highlightRange.lowerBound {
269+
range = range.lowerBound..<highlightRange.upperBound
270+
} else {
271+
break
272+
}
273+
}
274+
275+
let relatedInformation = diag.notes.compactMap { DiagnosticRelatedInformation($0, in: snapshot) }
276+
let codeActions = diag.fixIts.compactMap { CodeAction($0, in: snapshot) }
277+
278+
self.init(
279+
range: range,
280+
severity: diag.diagMessage.severity.lspSeverity,
281+
code: nil,
282+
codeDescription: nil,
283+
source: "SwiftSyntax",
284+
message: diag.message,
285+
tags: nil,
286+
relatedInformation: relatedInformation,
287+
codeActions: codeActions
288+
)
289+
}
237290
}
238291

239292
extension DiagnosticRelatedInformation {
@@ -271,74 +324,31 @@ extension DiagnosticRelatedInformation {
271324
codeActions: actions
272325
)
273326
}
274-
}
275-
276-
extension Diagnostic {
277-
func withRange(_ newRange: Range<Position>) -> Diagnostic {
278-
var updated = self
279-
updated.range = newRange
280-
return updated
281-
}
282-
}
283-
284-
struct CachedDiagnostic {
285-
var diagnostic: Diagnostic
286-
var stage: DiagnosticStage
287-
288-
func withRange(_ newRange: Range<Position>) -> CachedDiagnostic {
289-
return CachedDiagnostic(
290-
diagnostic: self.diagnostic.withRange(newRange),
291-
stage: self.stage
292-
)
293-
}
294-
}
295327

296-
extension CachedDiagnostic {
297-
init?(
298-
_ diag: SKDResponseDictionary,
299-
in snapshot: DocumentSnapshot,
300-
useEducationalNoteAsCode: Bool
301-
) {
302-
let sk = diag.sourcekitd
303-
guard
304-
let diagnostic = Diagnostic(
305-
diag,
306-
in: snapshot,
307-
useEducationalNoteAsCode: useEducationalNoteAsCode
328+
init?(_ note: Note, in snapshot: DocumentSnapshot) {
329+
let nodeRange = note.node.positionAfterSkippingLeadingTrivia..<note.node.endPositionBeforeTrailingTrivia
330+
guard let range = snapshot.range(of: nodeRange) else {
331+
logger.error(
332+
"""
333+
Cannot construct DiagnosticRelatedInformation because the range \(nodeRange, privacy: .public) \
334+
is out of range of the source file \(snapshot.uri.forLogging).
335+
"""
308336
)
309-
else {
310337
return nil
311338
}
312-
self.diagnostic = diagnostic
313-
let stageUID: sourcekitd_uid_t? = diag[sk.keys.diagnostic_stage]
314-
self.stage = stageUID.flatMap { DiagnosticStage($0, sourcekitd: sk) } ?? .parse
339+
self.init(
340+
location: Location(uri: snapshot.uri, range: range),
341+
message: note.message
342+
)
315343
}
316344
}
317345

318-
/// Returns the new diagnostics after merging in any existing diagnostics from a higher diagnostic
319-
/// stage that should not be cleared yet.
320-
///
321-
/// Sourcekitd returns parse diagnostics immediately after edits, but we do not want to clear the
322-
/// semantic diagnostics until we have semantic level diagnostics from after the edit.
323-
///
324-
/// However, if fallback arguments are being used, we withhold semantic diagnostics in favor of only
325-
/// emitting syntactic diagnostics.
326-
func mergeDiagnostics(
327-
old: [CachedDiagnostic],
328-
new: [CachedDiagnostic],
329-
stage: DiagnosticStage,
330-
isFallback: Bool
331-
) -> [CachedDiagnostic] {
332-
if stage == .sema {
333-
return isFallback ? old.filter { $0.stage == .parse } : new
334-
}
335-
336-
#if DEBUG
337-
if let sema = new.first(where: { $0.stage == .sema }) {
338-
logger.fault("unexpected semantic diagnostic in parse diagnostics \(String(reflecting: sema.diagnostic))")
346+
extension Diagnostic {
347+
func withRange(_ newRange: Range<Position>) -> Diagnostic {
348+
var updated = self
349+
updated.range = newRange
350+
return updated
339351
}
340-
#endif
341-
return new.filter { $0.stage == .parse } + old.filter { $0.stage == .sema }
342352
}
343353

344354
/// Whether a diagostic is semantic or syntatic (parse).
@@ -361,3 +371,14 @@ extension DiagnosticStage {
361371
}
362372
}
363373
}
374+
375+
fileprivate extension SwiftDiagnostics.DiagnosticSeverity {
376+
var lspSeverity: LanguageServerProtocol.DiagnosticSeverity {
377+
switch self {
378+
case .error: return .error
379+
case .warning: return .warning
380+
case .note: return .information
381+
case .remark: return .hint
382+
}
383+
}
384+
}

0 commit comments

Comments
 (0)