|
| 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 imMemoryModifiedFiles(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 | + |
| 116 | +/// A wrapper around `IndexStoreDB` that allows the retrieval of a `CheckedIndex` with a specified check level or the |
| 117 | +/// access of the underlying `IndexStoreDB`. This makes sure that accesses to the raw `IndexStoreDB` are explicit (by |
| 118 | +/// calling `underlyingIndexStoreDB`) and we don't accidentally call into the `IndexStoreDB` when we wanted a |
| 119 | +/// `CheckedIndex`. |
| 120 | +public struct UncheckedIndex { |
| 121 | + public let underlyingIndexStoreDB: IndexStoreDB |
| 122 | + |
| 123 | + public init?(_ index: IndexStoreDB?) { |
| 124 | + guard let index else { |
| 125 | + return nil |
| 126 | + } |
| 127 | + self.underlyingIndexStoreDB = index |
| 128 | + } |
| 129 | + |
| 130 | + public func checked(for checkLevel: IndexCheckLevel) -> CheckedIndex { |
| 131 | + return CheckedIndex(index: underlyingIndexStoreDB, checkLevel: checkLevel) |
| 132 | + } |
| 133 | +} |
| 134 | + |
| 135 | +/// Helper class to check if symbols from the index are up-to-date or if the source file has been modified after it was |
| 136 | +/// indexed. Modifications include both changes to the file on disk as well as modifications to the file that have not |
| 137 | +/// been saved to disk (ie. changes that only live in `DocumentManager`). |
| 138 | +/// |
| 139 | +/// The checker caches mod dates of source files. It should thus not be long lived. Its intended lifespan is the |
| 140 | +/// evaluation of a single request. |
| 141 | +private struct IndexOutOfDateChecker { |
| 142 | + private let checkLevel: IndexCheckLevel |
| 143 | + |
| 144 | + /// The last modification time of a file. Can also represent the fact that the file does not exist. |
| 145 | + private enum ModificationTime { |
| 146 | + case fileDoesNotExist |
| 147 | + case date(Date) |
| 148 | + } |
| 149 | + |
| 150 | + private enum Error: Swift.Error, CustomStringConvertible { |
| 151 | + case fileAttributesDontHaveModificationDate |
| 152 | + |
| 153 | + var description: String { |
| 154 | + switch self { |
| 155 | + case .fileAttributesDontHaveModificationDate: |
| 156 | + return "File attributes don't contain a modification date" |
| 157 | + } |
| 158 | + } |
| 159 | + } |
| 160 | + |
| 161 | + /// Caches whether a file URL has modifications in `documentManager` that haven't been saved to disk yet. |
| 162 | + private var fileHasInMemoryModificationsCache: [URL: Bool] = [:] |
| 163 | + |
| 164 | + /// File URLs to modification times that have already been computed. |
| 165 | + private var modTimeCache: [URL: ModificationTime] = [:] |
| 166 | + |
| 167 | + /// File URLs to whether they exist on the file system |
| 168 | + private var fileExistsCache: [URL: Bool] = [:] |
| 169 | + |
| 170 | + init(checkLevel: IndexCheckLevel) { |
| 171 | + self.checkLevel = checkLevel |
| 172 | + } |
| 173 | + |
| 174 | + // MARK: - Public interface |
| 175 | + |
| 176 | + /// Returns `true` if the source file for the given symbol location exists and has not been modified after it has been |
| 177 | + /// indexed. |
| 178 | + mutating func isUpToDate(_ symbolLocation: SymbolLocation) -> Bool { |
| 179 | + let url = URL(fileURLWithPath: symbolLocation.path, isDirectory: false) |
| 180 | + switch checkLevel { |
| 181 | + case .imMemoryModifiedFiles(let documentManager): |
| 182 | + if fileHasInMemoryModifications(at: url, documentManager: documentManager) { |
| 183 | + return false |
| 184 | + } |
| 185 | + fallthrough |
| 186 | + case .modifiedFiles: |
| 187 | + do { |
| 188 | + let sourceFileModificationDate = try modificationDate(of: url) |
| 189 | + switch sourceFileModificationDate { |
| 190 | + case .fileDoesNotExist: |
| 191 | + return false |
| 192 | + case .date(let sourceFileModificationDate): |
| 193 | + return sourceFileModificationDate <= symbolLocation.timestamp |
| 194 | + } |
| 195 | + } catch { |
| 196 | + logger.fault("Unable to determine if SymbolLocation is up-to-date: \(error.forLogging)") |
| 197 | + return true |
| 198 | + } |
| 199 | + case .deletedFiles: |
| 200 | + return fileExists(at: url) |
| 201 | + } |
| 202 | + } |
| 203 | + |
| 204 | + /// Return `true` if a unit file has been indexed for the given file path after its last modification date. |
| 205 | + /// |
| 206 | + /// This means that at least a single build configuration of this file has been indexed since its last modification. |
| 207 | + mutating func indexHasUpToDateUnit(for filePath: URL, index: IndexStoreDB) -> Bool { |
| 208 | + switch checkLevel { |
| 209 | + case .imMemoryModifiedFiles(let documentManager): |
| 210 | + if fileHasInMemoryModifications(at: filePath, documentManager: documentManager) { |
| 211 | + // If there are in-memory modifications to the file, we can't have an up-to-date unit since we only index files |
| 212 | + // on disk. |
| 213 | + return false |
| 214 | + } |
| 215 | + // If there are no in-memory modifications check if there are on-disk modifications. |
| 216 | + fallthrough |
| 217 | + case .modifiedFiles: |
| 218 | + guard let lastUnitDate = index.dateOfLatestUnitFor(filePath: filePath.path) else { |
| 219 | + return false |
| 220 | + } |
| 221 | + do { |
| 222 | + let sourceModificationDate = try modificationDate(of: filePath) |
| 223 | + switch sourceModificationDate { |
| 224 | + case .fileDoesNotExist: |
| 225 | + return false |
| 226 | + case .date(let sourceModificationDate): |
| 227 | + return sourceModificationDate <= lastUnitDate |
| 228 | + } |
| 229 | + } catch { |
| 230 | + logger.fault("Unable to determine if source file has up-to-date unit: \(error.forLogging)") |
| 231 | + return true |
| 232 | + } |
| 233 | + case .deletedFiles: |
| 234 | + // If we are asked if the index has an up-to-date unit for a source file, we can reasonably assume that this |
| 235 | + // source file exists (otherwise, why are we doing the query at all). Thus, there's nothing to check here. |
| 236 | + return true |
| 237 | + } |
| 238 | + } |
| 239 | + |
| 240 | + // MARK: - Cached check primitives |
| 241 | + |
| 242 | + private func fileHasInMemoryModificationsUncached(at url: URL, documentManager: DocumentManager) -> Bool { |
| 243 | + guard let document = try? documentManager.latestSnapshot(DocumentURI(url)) else { |
| 244 | + return false |
| 245 | + } |
| 246 | + |
| 247 | + guard let onDiskFileContents = try? String(contentsOf: url, encoding: .utf8) else { |
| 248 | + // If we can't read the file on disk, it can't match any on-disk state, so it's in-memory state |
| 249 | + return true |
| 250 | + } |
| 251 | + return onDiskFileContents != document.lineTable.content |
| 252 | + } |
| 253 | + |
| 254 | + /// `documentManager` must always be the same between calls to `hasFileInMemoryModifications` since it is not part of |
| 255 | + /// the cache key. This is fine because we always assume the `documentManager` to come from the associated value of |
| 256 | + /// `CheckLevel.imMemoryModifiedFiles`, which is constant. |
| 257 | + private mutating func fileHasInMemoryModifications(at url: URL, documentManager: DocumentManager) -> Bool { |
| 258 | + if let cached = fileHasInMemoryModificationsCache[url] { |
| 259 | + return cached |
| 260 | + } |
| 261 | + let hasInMemoryModifications = fileHasInMemoryModificationsUncached(at: url, documentManager: documentManager) |
| 262 | + fileHasInMemoryModificationsCache[url] = hasInMemoryModifications |
| 263 | + return hasInMemoryModifications |
| 264 | + } |
| 265 | + |
| 266 | + private func modificationDateUncached(of url: URL) throws -> ModificationTime { |
| 267 | + do { |
| 268 | + let attributes = try FileManager.default.attributesOfItem(atPath: url.resolvingSymlinksInPath().path) |
| 269 | + guard let modificationDate = attributes[FileAttributeKey.modificationDate] as? Date else { |
| 270 | + throw Error.fileAttributesDontHaveModificationDate |
| 271 | + } |
| 272 | + return .date(modificationDate) |
| 273 | + } catch let error as NSError where error.domain == NSCocoaErrorDomain && error.code == NSFileReadNoSuchFileError { |
| 274 | + return .fileDoesNotExist |
| 275 | + } |
| 276 | + } |
| 277 | + |
| 278 | + private mutating func modificationDate(of url: URL) throws -> ModificationTime { |
| 279 | + if let cached = modTimeCache[url] { |
| 280 | + return cached |
| 281 | + } |
| 282 | + let modTime = try modificationDateUncached(of: url) |
| 283 | + modTimeCache[url] = modTime |
| 284 | + return modTime |
| 285 | + } |
| 286 | + |
| 287 | + private mutating func fileExists(at url: URL) -> Bool { |
| 288 | + if let cached = fileExistsCache[url] { |
| 289 | + return cached |
| 290 | + } |
| 291 | + let fileExists = FileManager.default.fileExists(atPath: url.path) |
| 292 | + fileExistsCache[url] = fileExists |
| 293 | + return fileExists |
| 294 | + } |
| 295 | +} |
0 commit comments