Skip to content

Commit 47607d6

Browse files
authored
Merge pull request #1194 from ahoppen/check-index-up-to-date
Filter index entries for deleted source files
2 parents ab68dc5 + f23d5d6 commit 47607d6

10 files changed

+442
-191
lines changed

Sources/SKCore/MainFilesProvider.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import LanguageServerProtocol
1414

1515
/// A type that can provide the set of main files that include a particular file.
16-
public protocol MainFilesProvider: AnyObject, Sendable {
16+
public protocol MainFilesProvider: Sendable {
1717

1818
/// Returns the set of main files that contain the given file.
1919
///

Sources/SourceKitLSP/CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11

22
add_library(SourceKitLSP STATIC
33
CapabilityRegistry.swift
4+
CheckedIndex.swift
45
DocumentManager.swift
56
DocumentSnapshot+FromFileContents.swift
6-
IndexOutOfDateChecker.swift
77
IndexStoreDB+MainFilesProvider.swift
88
LanguageService.swift
99
Rename.swift
Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2020 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+
import Foundation
14+
import IndexStoreDB
15+
import LSPLogging
16+
import LanguageServerProtocol
17+
18+
public enum IndexCheckLevel {
19+
/// Consider the index out-of-date only if the source file has been deleted on disk.
20+
///
21+
/// This is usually a good default because: When a file gets modified, it's likely that some of the line:column
22+
/// locations in it are still correct – eg. if only one line is modified and if lines are inserted/deleted all
23+
/// locations above are still correct.
24+
/// For locations that are out of date, showing stale results is one of the best ways of communicating to the user
25+
/// that the index is out-of-date and that they need to rebuild. We might want to reconsider this default when we have
26+
/// background indexing.
27+
case deletedFiles
28+
29+
/// Consider the index out-of-date if the source file has been deleted or modified on disk.
30+
case modifiedFiles
31+
32+
/// Consider the index out-of-date if the source file has been deleted or modified on disk or if there are
33+
/// in-memory modifications in the given `DocumentManager`.
34+
case inMemoryModifiedFiles(DocumentManager)
35+
}
36+
37+
/// A wrapper around `IndexStoreDB` that checks if returned symbol occurrences are up-to-date with regard to a
38+
/// `IndexCheckLevel`.
39+
///
40+
/// - SeeAlso: Comment on `IndexOutOfDateChecker`
41+
public class CheckedIndex {
42+
private var checker: IndexOutOfDateChecker
43+
private let index: IndexStoreDB
44+
45+
fileprivate init(index: IndexStoreDB, checkLevel: IndexCheckLevel) {
46+
self.index = index
47+
self.checker = IndexOutOfDateChecker(checkLevel: checkLevel)
48+
}
49+
50+
@discardableResult
51+
public func forEachSymbolOccurrence(
52+
byUSR usr: String,
53+
roles: SymbolRole,
54+
_ body: @escaping (SymbolOccurrence) -> Bool
55+
) -> Bool {
56+
index.forEachSymbolOccurrence(byUSR: usr, roles: roles) { occurrence in
57+
guard self.checker.isUpToDate(occurrence.location) else {
58+
return true // continue
59+
}
60+
return body(occurrence)
61+
}
62+
}
63+
64+
public func occurrences(ofUSR usr: String, roles: SymbolRole) -> [SymbolOccurrence] {
65+
return index.occurrences(ofUSR: usr, roles: roles).filter { checker.isUpToDate($0.location) }
66+
}
67+
68+
public func occurrences(relatedToUSR usr: String, roles: SymbolRole) -> [SymbolOccurrence] {
69+
return index.occurrences(relatedToUSR: usr, roles: roles).filter { checker.isUpToDate($0.location) }
70+
}
71+
72+
@discardableResult public func forEachCanonicalSymbolOccurrence(
73+
containing pattern: String,
74+
anchorStart: Bool,
75+
anchorEnd: Bool,
76+
subsequence: Bool,
77+
ignoreCase: Bool,
78+
body: @escaping (SymbolOccurrence) -> Bool
79+
) -> Bool {
80+
index.forEachCanonicalSymbolOccurrence(
81+
containing: pattern,
82+
anchorStart: anchorStart,
83+
anchorEnd: anchorEnd,
84+
subsequence: subsequence,
85+
ignoreCase: ignoreCase
86+
) { occurrence in
87+
guard self.checker.isUpToDate(occurrence.location) else {
88+
return true // continue
89+
}
90+
return body(occurrence)
91+
}
92+
}
93+
94+
public func symbolProvider(for sourceFilePath: String) -> SymbolProviderKind? {
95+
return index.symbolProvider(for: sourceFilePath)
96+
}
97+
98+
/// Returns all unit test symbol in unit files that reference one of the main files in `mainFilePaths`.
99+
public func unitTests(referencedByMainFiles mainFilePaths: [String]) -> [SymbolOccurrence] {
100+
return index.unitTests(referencedByMainFiles: mainFilePaths).filter { checker.isUpToDate($0.location) }
101+
}
102+
103+
/// Returns all unit test symbols in the index.
104+
public func unitTests() -> [SymbolOccurrence] {
105+
return index.unitTests().filter { checker.isUpToDate($0.location) }
106+
}
107+
108+
/// Return `true` if a unit file has been indexed for the given file path after its last modification date.
109+
///
110+
/// This means that at least a single build configuration of this file has been indexed since its last modification.
111+
public func hasUpToDateUnit(for url: URL) -> Bool {
112+
return checker.indexHasUpToDateUnit(for: url, index: index)
113+
}
114+
115+
/// Returns true if the file at the given URL has a different content in the document manager than on-disk. This is
116+
/// the case if the user made edits to the file but didn't save them yet.
117+
///
118+
/// - Important: This must only be called on a `CheckedIndex` with a `checkLevel` of `inMemoryModifiedFiles`
119+
public func fileHasInMemoryModifications(_ url: URL) -> Bool {
120+
return checker.fileHasInMemoryModifications(url)
121+
}
122+
}
123+
124+
/// A wrapper around `IndexStoreDB` that allows the retrieval of a `CheckedIndex` with a specified check level or the
125+
/// access of the underlying `IndexStoreDB`. This makes sure that accesses to the raw `IndexStoreDB` are explicit (by
126+
/// calling `underlyingIndexStoreDB`) and we don't accidentally call into the `IndexStoreDB` when we wanted a
127+
/// `CheckedIndex`.
128+
public struct UncheckedIndex {
129+
public let underlyingIndexStoreDB: IndexStoreDB
130+
131+
public init?(_ index: IndexStoreDB?) {
132+
guard let index else {
133+
return nil
134+
}
135+
self.underlyingIndexStoreDB = index
136+
}
137+
138+
public func checked(for checkLevel: IndexCheckLevel) -> CheckedIndex {
139+
return CheckedIndex(index: underlyingIndexStoreDB, checkLevel: checkLevel)
140+
}
141+
}
142+
143+
/// Helper class to check if symbols from the index are up-to-date or if the source file has been modified after it was
144+
/// indexed. Modifications include both changes to the file on disk as well as modifications to the file that have not
145+
/// been saved to disk (ie. changes that only live in `DocumentManager`).
146+
///
147+
/// The checker caches mod dates of source files. It should thus not be long lived. Its intended lifespan is the
148+
/// evaluation of a single request.
149+
private struct IndexOutOfDateChecker {
150+
private let checkLevel: IndexCheckLevel
151+
152+
/// The last modification time of a file. Can also represent the fact that the file does not exist.
153+
private enum ModificationTime {
154+
case fileDoesNotExist
155+
case date(Date)
156+
}
157+
158+
private enum Error: Swift.Error, CustomStringConvertible {
159+
case fileAttributesDontHaveModificationDate
160+
161+
var description: String {
162+
switch self {
163+
case .fileAttributesDontHaveModificationDate:
164+
return "File attributes don't contain a modification date"
165+
}
166+
}
167+
}
168+
169+
/// Caches whether a file URL has modifications in `documentManager` that haven't been saved to disk yet.
170+
private var fileHasInMemoryModificationsCache: [URL: Bool] = [:]
171+
172+
/// File URLs to modification times that have already been computed.
173+
private var modTimeCache: [URL: ModificationTime] = [:]
174+
175+
/// File URLs to whether they exist on the file system
176+
private var fileExistsCache: [URL: Bool] = [:]
177+
178+
init(checkLevel: IndexCheckLevel) {
179+
self.checkLevel = checkLevel
180+
}
181+
182+
// MARK: - Public interface
183+
184+
/// Returns `true` if the source file for the given symbol location exists and has not been modified after it has been
185+
/// indexed.
186+
mutating func isUpToDate(_ symbolLocation: SymbolLocation) -> Bool {
187+
let url = URL(fileURLWithPath: symbolLocation.path, isDirectory: false)
188+
switch checkLevel {
189+
case .inMemoryModifiedFiles(let documentManager):
190+
if fileHasInMemoryModifications(url, documentManager: documentManager) {
191+
return false
192+
}
193+
fallthrough
194+
case .modifiedFiles:
195+
do {
196+
let sourceFileModificationDate = try modificationDate(of: url)
197+
switch sourceFileModificationDate {
198+
case .fileDoesNotExist:
199+
return false
200+
case .date(let sourceFileModificationDate):
201+
return sourceFileModificationDate <= symbolLocation.timestamp
202+
}
203+
} catch {
204+
logger.fault("Unable to determine if SymbolLocation is up-to-date: \(error.forLogging)")
205+
return true
206+
}
207+
case .deletedFiles:
208+
return fileExists(at: url)
209+
}
210+
}
211+
212+
/// Return `true` if a unit file has been indexed for the given file path after its last modification date.
213+
///
214+
/// This means that at least a single build configuration of this file has been indexed since its last modification.
215+
mutating func indexHasUpToDateUnit(for filePath: URL, index: IndexStoreDB) -> Bool {
216+
switch checkLevel {
217+
case .inMemoryModifiedFiles(let documentManager):
218+
if fileHasInMemoryModifications(filePath, documentManager: documentManager) {
219+
// If there are in-memory modifications to the file, we can't have an up-to-date unit since we only index files
220+
// on disk.
221+
return false
222+
}
223+
// If there are no in-memory modifications check if there are on-disk modifications.
224+
fallthrough
225+
case .modifiedFiles:
226+
guard let lastUnitDate = index.dateOfLatestUnitFor(filePath: filePath.path) else {
227+
return false
228+
}
229+
do {
230+
let sourceModificationDate = try modificationDate(of: filePath)
231+
switch sourceModificationDate {
232+
case .fileDoesNotExist:
233+
return false
234+
case .date(let sourceModificationDate):
235+
return sourceModificationDate <= lastUnitDate
236+
}
237+
} catch {
238+
logger.fault("Unable to determine if source file has up-to-date unit: \(error.forLogging)")
239+
return true
240+
}
241+
case .deletedFiles:
242+
// If we are asked if the index has an up-to-date unit for a source file, we can reasonably assume that this
243+
// source file exists (otherwise, why are we doing the query at all). Thus, there's nothing to check here.
244+
return true
245+
}
246+
}
247+
248+
// MARK: - Cached check primitives
249+
250+
/// `documentManager` must always be the same between calls to `hasFileInMemoryModifications` since it is not part of
251+
/// the cache key. This is fine because we always assume the `documentManager` to come from the associated value of
252+
/// `CheckLevel.imMemoryModifiedFiles`, which is constant.
253+
private mutating func fileHasInMemoryModifications(_ url: URL, documentManager: DocumentManager) -> Bool {
254+
if let cached = fileHasInMemoryModificationsCache[url] {
255+
return cached
256+
}
257+
let hasInMemoryModifications = documentManager.fileHasInMemoryModifications(url)
258+
fileHasInMemoryModificationsCache[url] = hasInMemoryModifications
259+
return hasInMemoryModifications
260+
}
261+
262+
/// Returns true if the file at the given URL has a different content in the document manager than on-disk. This is
263+
/// the case if the user made edits to the file but didn't save them yet.
264+
///
265+
/// - Important: This must only be called on an `IndexOutOfDateChecker` with a `checkLevel` of `inMemoryModifiedFiles`
266+
mutating func fileHasInMemoryModifications(_ url: URL) -> Bool {
267+
switch checkLevel {
268+
case .inMemoryModifiedFiles(let documentManager):
269+
return fileHasInMemoryModifications(url, documentManager: documentManager)
270+
case .modifiedFiles, .deletedFiles:
271+
logger.fault(
272+
"fileHasInMemoryModifications(at:) must only be called on an `IndexOutOfDateChecker` with check level .inMemoryModifiedFiles"
273+
)
274+
return false
275+
}
276+
}
277+
278+
private func modificationDateUncached(of url: URL) throws -> ModificationTime {
279+
do {
280+
let attributes = try FileManager.default.attributesOfItem(atPath: url.resolvingSymlinksInPath().path)
281+
guard let modificationDate = attributes[FileAttributeKey.modificationDate] as? Date else {
282+
throw Error.fileAttributesDontHaveModificationDate
283+
}
284+
return .date(modificationDate)
285+
} catch let error as NSError where error.domain == NSCocoaErrorDomain && error.code == NSFileReadNoSuchFileError {
286+
return .fileDoesNotExist
287+
}
288+
}
289+
290+
private mutating func modificationDate(of url: URL) throws -> ModificationTime {
291+
if let cached = modTimeCache[url] {
292+
return cached
293+
}
294+
let modTime = try modificationDateUncached(of: url)
295+
modTimeCache[url] = modTime
296+
return modTime
297+
}
298+
299+
private mutating func fileExists(at url: URL) -> Bool {
300+
if let cached = fileExistsCache[url] {
301+
return cached
302+
}
303+
let fileExists = FileManager.default.fileExists(atPath: url.path)
304+
fileExistsCache[url] = fileExists
305+
return fileExists
306+
}
307+
}
308+
309+
extension DocumentManager {
310+
/// Returns true if the file at the given URL has a different content in the document manager than on-disk. This is
311+
/// the case if the user made edits to the file but didn't save them yet.
312+
func fileHasInMemoryModifications(_ url: URL) -> Bool {
313+
guard let document = try? latestSnapshot(DocumentURI(url)) else {
314+
return false
315+
}
316+
317+
guard let onDiskFileContents = try? String(contentsOf: url, encoding: .utf8) else {
318+
// If we can't read the file on disk, it can't match any on-disk state, so it's in-memory state
319+
return true
320+
}
321+
return onDiskFileContents != document.lineTable.content
322+
}
323+
}

0 commit comments

Comments
 (0)