Skip to content

Commit 2ae2e70

Browse files
add LRUCache struct to SKUtilities
1 parent c24f92d commit 2ae2e70

File tree

7 files changed

+338
-104
lines changed

7 files changed

+338
-104
lines changed

Sources/DocCDocumentation/DocCCatalogIndexManager.swift

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,29 @@
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+
21+
/// The cache of DocCCatalogIndex for a given SwiftDocC catalog URL
22+
///
23+
/// - Note: The capacity has been chosen without scientific measurements. The
24+
/// feeling is that switching between SwiftDocC catalogs is rare and 5 catalog
25+
/// indexes won't take up much memory.
26+
private var indexCache = LRUCache<URL, Result<DocCCatalogIndex, DocCIndexError>>(capacity: 5)
2027

2128
init(server: DocCServer) {
2229
self.server = server
2330
}
2431

2532
func invalidate(_ url: URL) {
26-
catalogToIndexMap.removeValue(forKey: url)
33+
indexCache.removeValue(forKey: url)
2734
}
2835

2936
func index(for catalogURL: URL) async throws(DocCIndexError) -> DocCCatalogIndex {
30-
if let existingCatalog = catalogToIndexMap[catalogURL] {
37+
if let existingCatalog = indexCache[catalogURL] {
3138
return try existingCatalog.get()
3239
}
3340
do {
@@ -49,15 +56,15 @@ final actor DocCCatalogIndexManager {
4956
}
5057
let renderReferenceStore = try JSONDecoder().decode(RenderReferenceStore.self, from: renderReferenceStoreData)
5158
let catalogIndex = DocCCatalogIndex(from: renderReferenceStore)
52-
catalogToIndexMap[catalogURL] = .success(catalogIndex)
59+
indexCache[catalogURL] = .success(catalogIndex)
5360
return catalogIndex
5461
} catch {
5562
// Don't cache cancellation errors
5663
guard !(error is CancellationError) else {
5764
throw .cancelled
5865
}
5966
let internalError = error as? DocCIndexError ?? DocCIndexError.internalError(error)
60-
catalogToIndexMap[catalogURL] = .failure(internalError)
67+
indexCache[catalogURL] = .failure(internalError)
6168
throw internalError
6269
}
6370
}

Sources/SKUtilities/LRUCache.swift

Lines changed: 140 additions & 0 deletions
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+
/// - Note: 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+
/// - Note: 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+
/// Adds the given key as the first priority in the doubly linked list of priorities.
59+
private mutating func addPriority(forKey key: Key) {
60+
// Make sure the key doesn't already exist in the list
61+
removePriority(forKey: key)
62+
63+
guard let currentFirstPriority = firstPriority else {
64+
firstPriority = key
65+
lastPriority = key
66+
priorities[key] = Priority()
67+
return
68+
}
69+
priorities[key] = Priority(next: currentFirstPriority)
70+
priorities[currentFirstPriority]?.previous = key
71+
firstPriority = key
72+
}
73+
74+
/// Removes the given key from the doubly linked list of priorities.
75+
private mutating func removePriority(forKey key: Key) {
76+
guard let priority = priorities.removeValue(forKey: key) else {
77+
return
78+
}
79+
// Update the first and last priorities
80+
if firstPriority == key {
81+
firstPriority = priority.next
82+
}
83+
if lastPriority == key {
84+
lastPriority = priority.previous
85+
}
86+
// Update the previous and next keys in the priority list
87+
if let previousPriority = priority.previous {
88+
priorities[previousPriority]?.next = priority.next
89+
}
90+
if let nextPriority = priority.next {
91+
priorities[nextPriority]?.previous = priority.previous
92+
}
93+
}
94+
95+
/// Removes all key-value pairs from the cache.
96+
package mutating func removeAll() {
97+
cache.removeAll()
98+
priorities.removeAll()
99+
firstPriority = nil
100+
lastPriority = nil
101+
}
102+
103+
/// Removes all the elements that satisfy the given predicate.
104+
package mutating func removeAll(where shouldBeRemoved: (_: ((key: Key, value: Value)) throws -> Bool)) rethrows {
105+
cache = try cache.filter { entry in
106+
guard try shouldBeRemoved(entry) else {
107+
return true
108+
}
109+
removePriority(forKey: entry.key)
110+
return false
111+
}
112+
}
113+
114+
/// Removes the given key and its associated value from the cache.
115+
///
116+
/// Returns the value that was associated with the key.
117+
@discardableResult
118+
package mutating func removeValue(forKey key: Key) -> Value? {
119+
removePriority(forKey: key)
120+
return cache.removeValue(forKey: key)
121+
}
122+
123+
package subscript(key: Key) -> Value? {
124+
mutating _read {
125+
addPriority(forKey: key)
126+
yield cache[key]
127+
}
128+
set {
129+
guard let newValue else {
130+
removeValue(forKey: key)
131+
return
132+
}
133+
cache[key] = newValue
134+
addPriority(forKey: key)
135+
if cache.count > capacity, let lastPriority {
136+
removeValue(forKey: lastPriority)
137+
}
138+
}
139+
}
140+
}

Sources/SourceKitLSP/Swift/DiagnosticReportManager.swift

Lines changed: 13 additions & 29 deletions
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,28 +182,18 @@ 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.
196-
///
197-
/// If we are already storing `cacheSize` many reports, the oldest one
198-
/// will get discarded.
199190
private func setReportTask(
200191
for snapshotID: DocumentSnapshot.ID,
201192
buildSettings: SwiftCompileCommand?,
202193
reportTask: ReportTask
203194
) {
204195
// Remove any reportTasks for old versions of this document.
205-
reportTaskCache.removeAll(where: { $0.snapshotID <= snapshotID })
206-
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-
}
196+
reportTaskCache.removeAll(where: { $0.key.snapshotID <= snapshotID })
197+
reportTaskCache[CacheKey(snapshotID: snapshotID, buildSettings: buildSettings)] = reportTask
214198
}
215199
}

Sources/SourceKitLSP/Swift/MacroExpansion.swift

Lines changed: 12 additions & 41 deletions
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

Lines changed: 1 addition & 1 deletion
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)