Skip to content

Commit cc1e404

Browse files
add LRUCache struct to SKUtilities
1 parent c24f92d commit cc1e404

File tree

7 files changed

+288
-97
lines changed

7 files changed

+288
-97
lines changed

Sources/DocCDocumentation/DocCCatalogIndexManager.swift

+6-5
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,23 @@
1212

1313
package import Foundation
1414
import SKLogging
15+
import SKUtilities
1516
@_spi(LinkCompletion) @preconcurrency import SwiftDocC
1617

1718
final actor DocCCatalogIndexManager {
1819
private let server: DocCServer
19-
private var catalogToIndexMap: [URL: Result<DocCCatalogIndex, DocCIndexError>] = [:]
20+
private var indexCache = LRUCache<URL, Result<DocCCatalogIndex, DocCIndexError>>(capacity: 5)
2021

2122
init(server: DocCServer) {
2223
self.server = server
2324
}
2425

2526
func invalidate(_ url: URL) {
26-
catalogToIndexMap.removeValue(forKey: url)
27+
indexCache.removeValue(forKey: url)
2728
}
2829

2930
func index(for catalogURL: URL) async throws(DocCIndexError) -> DocCCatalogIndex {
30-
if let existingCatalog = catalogToIndexMap[catalogURL] {
31+
if let existingCatalog = indexCache[catalogURL] {
3132
return try existingCatalog.get()
3233
}
3334
do {
@@ -49,15 +50,15 @@ final actor DocCCatalogIndexManager {
4950
}
5051
let renderReferenceStore = try JSONDecoder().decode(RenderReferenceStore.self, from: renderReferenceStoreData)
5152
let catalogIndex = DocCCatalogIndex(from: renderReferenceStore)
52-
catalogToIndexMap[catalogURL] = .success(catalogIndex)
53+
indexCache[catalogURL] = .success(catalogIndex)
5354
return catalogIndex
5455
} catch {
5556
// Don't cache cancellation errors
5657
guard !(error is CancellationError) else {
5758
throw .cancelled
5859
}
5960
let internalError = error as? DocCIndexError ?? DocCIndexError.internalError(error)
60-
catalogToIndexMap[catalogURL] = .failure(internalError)
61+
indexCache[catalogURL] = .failure(internalError)
6162
throw internalError
6263
}
6364
}

Sources/SKUtilities/LRUCache.swift

+140
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2025 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
/// A cache that stores key-value pairs up to a given capacity.
14+
///
15+
/// The least recently used key-value pair is removed when the cache exceeds its capacity.
16+
package struct LRUCache<Key: Hashable, Value> {
17+
private struct Priority {
18+
var next: Key?
19+
var previous: Key?
20+
21+
init(next: Key? = nil, previous: Key? = nil) {
22+
self.next = next
23+
self.previous = previous
24+
}
25+
}
26+
27+
// The hash map for accessing cached key-value pairs.
28+
private var cache: [Key: Value]
29+
30+
// Doubly linked list of priorities keeping track of the first and last entries.
31+
private var priorities: [Key: Priority]
32+
private var firstPriority: Key? = nil
33+
private var lastPriority: Key? = nil
34+
35+
/// The maximum number of key-value pairs that can be stored in the cache.
36+
package let capacity: Int
37+
38+
/// The number of key-value pairs within the cache.
39+
package var count: Int { cache.count }
40+
41+
/// A collection containing just the keys of the cache.
42+
///
43+
/// Keys will **not** be in the same order that they were added to the cache.
44+
package var keys: any Collection<Key> { cache.keys }
45+
46+
/// A collection containing just the values of the cache.
47+
///
48+
/// Values will **not** be in the same order that they were added to the cache.
49+
package var values: any Collection<Value> { cache.values }
50+
51+
package init(capacity: Int) {
52+
assert(capacity > 0, "LRUCache capacity must be greater than 0")
53+
self.capacity = capacity
54+
self.cache = Dictionary(minimumCapacity: capacity)
55+
self.priorities = Dictionary(minimumCapacity: capacity)
56+
}
57+
58+
/// Assigns the given key as the first priority in the doubly linked list of priorities.
59+
private mutating func addPriority(forKey key: Key) {
60+
guard let currentFirstPriority = firstPriority else {
61+
firstPriority = key
62+
lastPriority = key
63+
priorities[key] = Priority()
64+
return
65+
}
66+
priorities[key] = Priority(next: currentFirstPriority)
67+
priorities[currentFirstPriority]?.previous = key
68+
firstPriority = key
69+
}
70+
71+
/// Removes the given key from the doubly linked list of priorities.
72+
private mutating func removePriority(forKey key: Key) {
73+
guard let priority = priorities.removeValue(forKey: key) else {
74+
return
75+
}
76+
// Update the first and last priorities
77+
if firstPriority == key {
78+
firstPriority = priority.next
79+
}
80+
if lastPriority == key {
81+
lastPriority = priority.previous
82+
}
83+
// Update the previous and next keys in the priority list
84+
if let previousPriority = priority.previous {
85+
priorities[previousPriority]?.next = priority.next
86+
}
87+
if let nextPriority = priority.next {
88+
priorities[nextPriority]?.previous = priority.previous
89+
}
90+
}
91+
92+
/// Removes all key-value pairs from the cache.
93+
package mutating func removeAll() {
94+
cache.removeAll()
95+
priorities.removeAll()
96+
firstPriority = nil
97+
lastPriority = nil
98+
}
99+
100+
/// Removes all the elements that satisfy the given predicate.
101+
package mutating func removeAll(where shouldBeRemoved: (_: ((key: Key, value: Value)) throws -> Bool)) rethrows {
102+
cache = try cache.filter { entry in
103+
guard try shouldBeRemoved(entry) else {
104+
return true
105+
}
106+
removePriority(forKey: entry.key)
107+
return false
108+
}
109+
}
110+
111+
/// Removes the given key and its associated value from the cache.
112+
///
113+
/// Returns the value that was associated with the key.
114+
@discardableResult
115+
package mutating func removeValue(forKey key: Key) -> Value? {
116+
removePriority(forKey: key)
117+
return cache.removeValue(forKey: key)
118+
}
119+
120+
package subscript(key: Key) -> Value? {
121+
mutating _read {
122+
removePriority(forKey: key)
123+
addPriority(forKey: key)
124+
yield cache[key]
125+
}
126+
set {
127+
guard let newValue else {
128+
removeValue(forKey: key)
129+
return
130+
}
131+
cache[key] = newValue
132+
// Move the key up the priority list by removing and then adding it
133+
removePriority(forKey: key)
134+
addPriority(forKey: key)
135+
if cache.count > capacity, let lastPriority {
136+
removeValue(forKey: lastPriority)
137+
}
138+
}
139+
}
140+
}

Sources/SourceKitLSP/Swift/DiagnosticReportManager.swift

+13-25
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import LanguageServerProtocol
1414
import LanguageServerProtocolExtensions
1515
import SKLogging
1616
import SKOptions
17+
import SKUtilities
1718
import SourceKitD
1819
import SwiftDiagnostics
1920
import SwiftExtensions
@@ -25,6 +26,11 @@ actor DiagnosticReportManager {
2526
(report: RelatedFullDocumentDiagnosticReport, cachable: Bool)
2627
>
2728

29+
private struct CacheKey: Hashable {
30+
let snapshotID: DocumentSnapshot.ID
31+
let buildSettings: SwiftCompileCommand?
32+
}
33+
2834
private let sourcekitd: SourceKitD
2935
private let options: SourceKitLSPOptions
3036
private let syntaxTreeManager: SyntaxTreeManager
@@ -36,20 +42,8 @@ actor DiagnosticReportManager {
3642

3743
/// The cache that stores reportTasks for snapshot id and buildSettings
3844
///
39-
/// Conceptually, this is a dictionary. To prevent excessive memory usage we
40-
/// only keep `cacheSize` entries within the array. Older entries are at the
41-
/// end of the list, newer entries at the front.
42-
private var reportTaskCache:
43-
[(
44-
snapshotID: DocumentSnapshot.ID,
45-
buildSettings: SwiftCompileCommand?,
46-
reportTask: ReportTask
47-
)] = []
48-
49-
/// The number of reportTasks to keep
50-
///
51-
/// - Note: This has been chosen without scientific measurements.
52-
private let cacheSize = 5
45+
/// - Note: The capacity has been chosen without scientific measurements.
46+
private var reportTaskCache = LRUCache<CacheKey, ReportTask>(capacity: 5)
5347

5448
init(
5549
sourcekitd: SourceKitD,
@@ -101,7 +95,7 @@ actor DiagnosticReportManager {
10195
}
10296

10397
func removeItemsFromCache(with uri: DocumentURI) async {
104-
reportTaskCache.removeAll(where: { $0.snapshotID.uri == uri })
98+
reportTaskCache.removeAll(where: { $0.key.snapshotID.uri == uri })
10599
}
106100

107101
private func requestReport(
@@ -188,8 +182,8 @@ actor DiagnosticReportManager {
188182
for snapshotID: DocumentSnapshot.ID,
189183
buildSettings: SwiftCompileCommand?
190184
) -> ReportTask? {
191-
return reportTaskCache.first(where: { $0.snapshotID == snapshotID && $0.buildSettings == buildSettings })?
192-
.reportTask
185+
let key = CacheKey(snapshotID: snapshotID, buildSettings: buildSettings)
186+
return reportTaskCache[key]
193187
}
194188

195189
/// Set the reportTask for the given document snapshot and buildSettings.
@@ -202,14 +196,8 @@ actor DiagnosticReportManager {
202196
reportTask: ReportTask
203197
) {
204198
// Remove any reportTasks for old versions of this document.
205-
reportTaskCache.removeAll(where: { $0.snapshotID <= snapshotID })
199+
reportTaskCache.removeAll(where: { $0.key.snapshotID <= snapshotID })
206200

207-
reportTaskCache.insert((snapshotID, buildSettings, reportTask), at: 0)
208-
209-
// If we still have more than `cacheSize` reportTasks, delete the ones that
210-
// were produced last. We can always re-request them on-demand.
211-
while reportTaskCache.count > cacheSize {
212-
reportTaskCache.removeLast()
213-
}
201+
reportTaskCache[CacheKey(snapshotID: snapshotID, buildSettings: buildSettings)] = reportTask
214202
}
215203
}

Sources/SourceKitLSP/Swift/MacroExpansion.swift

+12-41
Original file line numberDiff line numberDiff line change
@@ -22,26 +22,10 @@ import SwiftExtensions
2222

2323
/// Caches the contents of macro expansions that were recently requested by the user.
2424
actor MacroExpansionManager {
25-
private struct CacheEntry {
26-
// Key
25+
private struct CacheKey: Hashable {
2726
let snapshotID: DocumentSnapshot.ID
2827
let range: Range<Position>
2928
let buildSettings: SwiftCompileCommand?
30-
31-
// Value
32-
let value: [RefactoringEdit]
33-
34-
fileprivate init(
35-
snapshot: DocumentSnapshot,
36-
range: Range<Position>,
37-
buildSettings: SwiftCompileCommand?,
38-
value: [RefactoringEdit]
39-
) {
40-
self.snapshotID = snapshot.id
41-
self.range = range
42-
self.buildSettings = buildSettings
43-
self.value = value
44-
}
4529
}
4630

4731
init(swiftLanguageService: SwiftLanguageService?) {
@@ -50,19 +34,12 @@ actor MacroExpansionManager {
5034

5135
private weak var swiftLanguageService: SwiftLanguageService?
5236

53-
/// The number of macro expansions to cache.
54-
///
55-
/// - Note: This should be bigger than the maximum expansion depth of macros a user might do to avoid re-generating
56-
/// all parent macros to a nested macro expansion's buffer. 10 seems to be big enough for that because it's
57-
/// unlikely that a macro will expand to more than 10 levels.
58-
private let cacheSize = 10
59-
6037
/// The cache that stores reportTasks for a combination of uri, range and build settings.
6138
///
62-
/// Conceptually, this is a dictionary. To prevent excessive memory usage we
63-
/// only keep `cacheSize` entries within the array. Older entries are at the
64-
/// end of the list, newer entries at the front.
65-
private var cache: [CacheEntry] = []
39+
/// - Note: The capacity of this cache should be bigger than the maximum expansion depth of macros a user might
40+
/// do to avoid re-generating all parent macros to a nested macro expansion's buffer. 10 seems to be big enough
41+
/// for that because it's unlikely that a macro will expand to more than 10 levels.
42+
private var cache = LRUCache<CacheKey, [RefactoringEdit]>(capacity: 10)
6643

6744
/// Return the text of the macro expansion referenced by `macroExpansionURLData`.
6845
func macroExpansion(
@@ -90,20 +67,12 @@ actor MacroExpansionManager {
9067
let snapshot = try await swiftLanguageService.latestSnapshot(for: uri)
9168
let compileCommand = await swiftLanguageService.compileCommand(for: uri, fallbackAfterTimeout: false)
9269

93-
if let cacheEntry = cache.first(where: {
94-
$0.snapshotID == snapshot.id && $0.range == range && $0.buildSettings == compileCommand
95-
}) {
96-
return cacheEntry.value
70+
let cacheKey = CacheKey(snapshotID: snapshot.id, range: range, buildSettings: compileCommand)
71+
if let valueFromCache = cache[cacheKey] {
72+
return valueFromCache
9773
}
9874
let macroExpansions = try await macroExpansionsImpl(in: snapshot, at: range, buildSettings: compileCommand)
99-
cache.insert(
100-
CacheEntry(snapshot: snapshot, range: range, buildSettings: compileCommand, value: macroExpansions),
101-
at: 0
102-
)
103-
104-
while cache.count > cacheSize {
105-
cache.removeLast()
106-
}
75+
cache[cacheKey] = macroExpansions
10776

10877
return macroExpansions
10978
}
@@ -151,7 +120,9 @@ actor MacroExpansionManager {
151120

152121
/// Remove all cached macro expansions for the given primary file, eg. because the macro's plugin might have changed.
153122
func purge(primaryFile: DocumentURI) {
154-
cache.removeAll { $0.snapshotID.uri.primaryFile ?? $0.snapshotID.uri == primaryFile }
123+
cache.removeAll {
124+
$0.key.snapshotID.uri.primaryFile ?? $0.key.snapshotID.uri == primaryFile
125+
}
155126
}
156127
}
157128

Sources/SourceKitLSP/Swift/SwiftLanguageService.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ fileprivate func diagnosticsEnabled(for document: DocumentURI) -> Bool {
7373
}
7474

7575
/// A swift compiler command derived from a `FileBuildSettingsChange`.
76-
package struct SwiftCompileCommand: Sendable, Equatable {
76+
package struct SwiftCompileCommand: Sendable, Equatable, Hashable {
7777

7878
/// The compiler arguments, including working directory. This is required since sourcekitd only
7979
/// accepts the working directory via the compiler arguments.

0 commit comments

Comments
 (0)