Skip to content

Add cache to avoid requesting diagnostics from sourcekitd frequently. #965

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

Merged
merged 1 commit into from
Jan 12, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions Sources/SourceKitLSP/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ target_sources(SourceKitLSP PRIVATE
Swift/CommentXML.swift
Swift/CursorInfo.swift
Swift/Diagnostic.swift
Swift/DiagnosticReportManager.swift
Swift/DocumentSymbols.swift
Swift/EditorPlaceholder.swift
Swift/FoldingRange.swift
Expand Down
180 changes: 180 additions & 0 deletions Sources/SourceKitLSP/Swift/DiagnosticReportManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2020 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//

import LSPLogging
import LanguageServerProtocol
import SourceKitD
import SwiftParserDiagnostics

actor DiagnosticReportManager {
/// A task to produce diagnostics, either from a diagnostics request to `sourcektid` or by using the built-in swift-syntax.
private typealias ReportTask = Task<RelatedFullDocumentDiagnosticReport, Error>

private let sourcekitd: SourceKitD
private let syntaxTreeManager: SyntaxTreeManager
private let documentManager: DocumentManager
private let clientHasDiagnosticsCodeDescriptionSupport: Bool

private nonisolated var keys: sourcekitd_keys { return sourcekitd.keys }
private nonisolated var requests: sourcekitd_requests { return sourcekitd.requests }

/// The cache that stores reportTasks for snapshot id and buildSettings
///
/// Conceptually, this is a dictionary. To prevent excessive memory usage we
/// only keep `cacheSize` entries within the array. Older entries are at the
/// end of the list, newer entries at the front.
private var reportTaskCache:
[(
snapshotID: DocumentSnapshot.ID,
buildSettings: SwiftCompileCommand?,
reportTask: ReportTask
)] = []

/// The number of reportTasks to keep
///
/// - Note: This has been chosen without scientific measurements.
private let cacheSize = 5

init(
sourcekitd: SourceKitD,
syntaxTreeManager: SyntaxTreeManager,
documentManager: DocumentManager,
clientHasDiagnosticsCodeDescriptionSupport: Bool
) {
self.sourcekitd = sourcekitd
self.syntaxTreeManager = syntaxTreeManager
self.documentManager = documentManager
self.clientHasDiagnosticsCodeDescriptionSupport = clientHasDiagnosticsCodeDescriptionSupport
}

func diagnosticReport(
for snapshot: DocumentSnapshot,
buildSettings: SwiftCompileCommand?
) async throws -> RelatedFullDocumentDiagnosticReport {
if let reportTask = reportTask(for: snapshot.id, buildSettings: buildSettings) {
return try await reportTask.value
}
let reportTask: Task<RelatedFullDocumentDiagnosticReport, Error>
if let buildSettings, !buildSettings.isFallback {
reportTask = Task {
return try await requestReport(with: snapshot, compilerArgs: buildSettings.compilerArgs)
}
} else {
logger.log(
"Producing syntactic diagnostics from the built-in swift-syntax because we \(buildSettings != nil ? "have fallback build settings" : "don't have build settings", privacy: .public))"
)
// If we don't have build settings or we only have fallback build settings,
// sourcekitd won't be able to give us accurate semantic diagnostics.
// Fall back to providing syntactic diagnostics from the built-in
// swift-syntax. That's the best we can do for now.
reportTask = Task {
return try await requestFallbackReport(with: snapshot)
}
}
setReportTask(for: snapshot.id, buildSettings: buildSettings, reportTask: reportTask)
return try await reportTask.value
}

func removeItemsFromCache(with uri: DocumentURI) async {
for item in reportTaskCache {
if item.snapshotID.uri == uri {
item.reportTask.cancel()
}
}
reportTaskCache.removeAll(where: { $0.snapshotID.uri == uri })
}

private func requestReport(
with snapshot: DocumentSnapshot,
compilerArgs: [String]
) async throws -> LanguageServerProtocol.RelatedFullDocumentDiagnosticReport {
try Task.checkCancellation()

let keys = self.keys
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 necessary. keys should already refer to self.keys everywhere below.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

i checked the var keys the implementations is

private nonisolated var keys: sourcekitd_keys { return sourcekitd.keys }

i think the purpose is to ensure the value of keys won't change in func requestReport

the same pattern is happening in other files.
e.g. SwiftLanguageServer.swift, SemanticRefactoring.swift, etc.

Copy link
Member

Choose a reason for hiding this comment

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

Ah, OK. Fair enough.


let skreq = sourcekitd.dictionary([
keys.request: requests.diagnostics,
keys.sourcefile: snapshot.uri.pseudoPath,
keys.compilerargs: compilerArgs as [SKDValue],
])

let dict = try await self.sourcekitd.send(skreq, fileContents: snapshot.text)

try Task.checkCancellation()
guard (try? documentManager.latestSnapshot(snapshot.uri).id) == snapshot.id else {
// Check that the document wasn't modified while we were getting diagnostics. This could happen because we are
// calling `fullDocumentDiagnosticReport` from `publishDiagnosticsIfNeeded` outside of `messageHandlingQueue`
// and thus a concurrent edit is possible while we are waiting for the sourcekitd request to return a result.
throw ResponseError.unknown("Document was modified while loading diagnostics")
}

let diagnostics: [Diagnostic] =
dict[keys.diagnostics]?.compactMap({ diag in
Diagnostic(
diag,
in: snapshot,
useEducationalNoteAsCode: self.clientHasDiagnosticsCodeDescriptionSupport
)
}) ?? []

return RelatedFullDocumentDiagnosticReport(items: diagnostics)
}

private func requestFallbackReport(
with snapshot: DocumentSnapshot
) async throws -> LanguageServerProtocol.RelatedFullDocumentDiagnosticReport {
// If we don't have build settings or we only have fallback build settings,
// sourcekitd won't be able to give us accurate semantic diagnostics.
// Fall back to providing syntactic diagnostics from the built-in
// swift-syntax. That's the best we can do for now.
let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot)
let swiftSyntaxDiagnostics = ParseDiagnosticsGenerator.diagnostics(for: syntaxTree)
let diagnostics = swiftSyntaxDiagnostics.compactMap { (diag) -> Diagnostic? in
if diag.diagnosticID == StaticTokenError.editorPlaceholder.diagnosticID {
// Ignore errors about editor placeholders in the source file, similar to how sourcekitd ignores them.
return nil
}
return Diagnostic(diag, in: snapshot)
}
return RelatedFullDocumentDiagnosticReport(items: diagnostics)
}

/// The reportTask for the given document snapshot and buildSettings.
private func reportTask(
for snapshotID: DocumentSnapshot.ID,
buildSettings: SwiftCompileCommand?
) -> ReportTask? {
return reportTaskCache.first(where: { $0.snapshotID == snapshotID && $0.buildSettings == buildSettings })?
.reportTask
}

/// Set the reportTask for the given document snapshot and buildSettings.
///
/// If we are already storing `cacheSize` many reports, the oldest one
/// will get discarded.
private func setReportTask(
for snapshotID: DocumentSnapshot.ID,
buildSettings: SwiftCompileCommand?,
reportTask: ReportTask
) {
reportTaskCache.insert((snapshotID, buildSettings, reportTask), at: 0)

// Remove any reportTasks for old versions of this document.
reportTaskCache.removeAll(where: { $0.snapshotID < snapshotID })

// If we still have more than `cacheSize` reportTasks, delete the ones that
// were produced last. We can always re-request them on-demand.
while reportTaskCache.count > cacheSize {
reportTaskCache.removeLast()
}
}
}
97 changes: 28 additions & 69 deletions Sources/SourceKitLSP/Swift/SwiftLanguageServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ public actor SwiftLanguageServer: ToolchainLanguageServer {

private var stateChangeHandlers: [(_ oldState: LanguageServerState, _ newState: LanguageServerState) -> Void] = []

private let diagnosticReportManager: DiagnosticReportManager

/// Creates a language server for the given client using the sourcekitd dylib specified in `toolchain`.
/// `reopenDocuments` is a closure that will be called if sourcekitd crashes and the `SwiftLanguageServer` asks its parent server to reopen all of its documents.
/// Returns `nil` if `sourcektid` couldn't be found.
Expand All @@ -157,6 +159,12 @@ public actor SwiftLanguageServer: ToolchainLanguageServer {
self.state = .connected
self.generatedInterfacesPath = options.generatedInterfacesPath.asURL
try FileManager.default.createDirectory(at: generatedInterfacesPath, withIntermediateDirectories: true)
self.diagnosticReportManager = DiagnosticReportManager(
sourcekitd: self.sourcekitd,
syntaxTreeManager: syntaxTreeManager,
documentManager: documentManager,
clientHasDiagnosticsCodeDescriptionSupport: capabilityRegistry.clientHasDiagnosticsCodeDescriptionSupport
)
}

/// - Important: For testing only
Expand Down Expand Up @@ -270,6 +278,7 @@ extension SwiftLanguageServer {

private func reopenDocument(_ snapshot: DocumentSnapshot, _ compileCmd: SwiftCompileCommand?) async {
cancelInFlightPublishDiagnosticsTask(for: snapshot.uri)
await diagnosticReportManager.removeItemsFromCache(with: snapshot.id.uri)

let keys = self.keys
let path = snapshot.uri.pseudoPath
Expand Down Expand Up @@ -317,6 +326,7 @@ extension SwiftLanguageServer {

public func openDocument(_ note: DidOpenTextDocumentNotification) async {
cancelInFlightPublishDiagnosticsTask(for: note.textDocument.uri)
await diagnosticReportManager.removeItemsFromCache(with: note.textDocument.uri)

let keys = self.keys

Expand All @@ -340,6 +350,7 @@ extension SwiftLanguageServer {
public func closeDocument(_ note: DidCloseTextDocumentNotification) async {
cancelInFlightPublishDiagnosticsTask(for: note.textDocument.uri)
inFlightPublishDiagnosticsTasks[note.textDocument.uri] = nil
await diagnosticReportManager.removeItemsFromCache(with: note.textDocument.uri)

let keys = self.keys

Expand Down Expand Up @@ -394,8 +405,11 @@ extension SwiftLanguageServer {
return
}
do {
let diagnosticReport = try await self.fullDocumentDiagnosticReport(
DocumentDiagnosticsRequest(textDocument: TextDocumentIdentifier(document))
let snapshot = try await documentManager.latestSnapshot(document)
let buildSettings = await self.buildSettings(for: document)
let diagnosticReport = try await self.diagnosticReportManager.diagnosticReport(
for: snapshot,
buildSettings: buildSettings
)

await sourceKitServer.sendNotificationToClient(
Expand Down Expand Up @@ -697,10 +711,11 @@ extension SwiftLanguageServer {
}

func retrieveQuickFixCodeActions(_ params: CodeActionRequest) async throws -> [CodeAction] {
let diagnosticReport = try await self.fullDocumentDiagnosticReport(
DocumentDiagnosticsRequest(
textDocument: params.textDocument
)
let snapshot = try documentManager.latestSnapshot(params.textDocument.uri)
let buildSettings = await self.buildSettings(for: params.textDocument.uri)
let diagnosticReport = try await self.diagnosticReportManager.diagnosticReport(
for: snapshot,
buildSettings: buildSettings
)

let codeActions = diagnosticReport.items.flatMap { (diag) -> [CodeAction] in
Expand Down Expand Up @@ -775,24 +790,15 @@ extension SwiftLanguageServer {
return Array(hints)
}

public func syntacticDiagnosticFromBuiltInSwiftSyntax(
for snapshot: DocumentSnapshot
) async throws -> RelatedFullDocumentDiagnosticReport {
let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot)
let swiftSyntaxDiagnostics = ParseDiagnosticsGenerator.diagnostics(for: syntaxTree)
let diagnostics = swiftSyntaxDiagnostics.compactMap { (diag) -> Diagnostic? in
if diag.diagnosticID == StaticTokenError.editorPlaceholder.diagnosticID {
// Ignore errors about editor placeholders in the source file, similar to how sourcekitd ignores them.
return nil
}
return Diagnostic(diag, in: snapshot)
}
return RelatedFullDocumentDiagnosticReport(items: diagnostics)
}

public func documentDiagnostic(_ req: DocumentDiagnosticsRequest) async throws -> DocumentDiagnosticReport {
let snapshot = try documentManager.latestSnapshot(req.textDocument.uri)
let buildSettings = await self.buildSettings(for: req.textDocument.uri)
let diagnosticReport = try await self.diagnosticReportManager.diagnosticReport(
for: snapshot,
buildSettings: buildSettings
)
do {
return try await .full(fullDocumentDiagnosticReport(req))
return try await .full(diagnosticReport)
} catch {
// VS Code does not request diagnostics again for a document if the diagnostics request failed.
// Since sourcekit-lsp usually recovers from failures (e.g. after sourcekitd crashes), this is undesirable.
Expand All @@ -807,53 +813,6 @@ extension SwiftLanguageServer {
}
}

private func fullDocumentDiagnosticReport(
_ req: DocumentDiagnosticsRequest
) async throws -> RelatedFullDocumentDiagnosticReport {
let snapshot = try documentManager.latestSnapshot(req.textDocument.uri)
guard let buildSettings = await self.buildSettings(for: req.textDocument.uri), !buildSettings.isFallback else {
logger.log(
"Producing syntactic diagnostics from the built-in swift-syntax because we have fallback arguments"
)
// If we don't have build settings or we only have fallback build settings,
// sourcekitd won't be able to give us accurate semantic diagnostics.
// Fall back to providing syntactic diagnostics from the built-in
// swift-syntax. That's the best we can do for now.
return try await syntacticDiagnosticFromBuiltInSwiftSyntax(for: snapshot)
}

try Task.checkCancellation()

let keys = self.keys

let skreq = sourcekitd.dictionary([
keys.request: requests.diagnostics,
keys.sourcefile: snapshot.uri.pseudoPath,
keys.compilerargs: buildSettings.compilerArgs as [SKDValue],
])

let dict = try await self.sourcekitd.send(skreq, fileContents: snapshot.text)

try Task.checkCancellation()
guard (try? documentManager.latestSnapshot(req.textDocument.uri).id) == snapshot.id else {
// Check that the document wasn't modified while we were getting diagnostics. This could happen because we are
// calling `fullDocumentDiagnosticReport` from `publishDiagnosticsIfNeeded` outside of `messageHandlingQueue`
// and thus a concurrent edit is possible while we are waiting for the sourcekitd request to return a result.
throw ResponseError.unknown("Document was modified while loading document")
}

let supportsCodeDescription = capabilityRegistry.clientHasDiagnosticsCodeDescriptionSupport
var diagnostics: [Diagnostic] = []
dict[keys.diagnostics]?.forEach { _, diag in
if let diag = Diagnostic(diag, in: snapshot, useEducationalNoteAsCode: supportsCodeDescription) {
diagnostics.append(diag)
}
return true
}

return RelatedFullDocumentDiagnosticReport(items: diagnostics)
}

public func executeCommand(_ req: ExecuteCommandRequest) async throws -> LSPAny? {
// TODO: If there's support for several types of commands, we might need to structure this similarly to the code actions request.
guard let sourceKitServer else {
Expand Down
Loading