diff --git a/Sources/Diagnose/SourcekitdRequestCommand.swift b/Sources/Diagnose/SourcekitdRequestCommand.swift index c4cb8665d..14cabbad4 100644 --- a/Sources/Diagnose/SourcekitdRequestCommand.swift +++ b/Sources/Diagnose/SourcekitdRequestCommand.swift @@ -56,10 +56,9 @@ public struct SourceKitdRequestCommand: AsyncParsableCommand { let requestInfo = try RequestInfo(request: requestString) let lineTable = LineTable(requestInfo.fileContents) - if let offset = lineTable.utf8OffsetOf(line: line - 1, utf8Column: column - 1) { - print("Adjusting request offset to \(offset)") - requestString.replace(#/key.offset: [0-9]+/#, with: "key.offset: \(offset)") - } + let offset = lineTable.utf8OffsetOf(line: line - 1, utf8Column: column - 1) + print("Adjusting request offset to \(offset)") + requestString.replace(#/key.offset: [0-9]+/#, with: "key.offset: \(offset)") } let request = try requestString.cString(using: .utf8)!.withUnsafeBufferPointer { buffer in diff --git a/Sources/LSPLogging/OrLog.swift b/Sources/LSPLogging/OrLog.swift index 4c8235518..dba64ab33 100644 --- a/Sources/LSPLogging/OrLog.swift +++ b/Sources/LSPLogging/OrLog.swift @@ -14,7 +14,7 @@ import os #endif -public func logError(prefix: String, error: Error, level: LogLevel = .error) { +private func logError(prefix: String, error: Error, level: LogLevel = .error) { logger.log( level: level, "\(prefix, privacy: .public)\(prefix.isEmpty ? "" : ": ", privacy: .public)\(error.forLogging)" @@ -41,7 +41,7 @@ public func orLog( public func orLog( _ prefix: String, level: LogLevel = .error, - _ block: () async throws -> R? + @_inheritActorContext _ block: @Sendable () async throws -> R? ) async -> R? { do { return try await block() diff --git a/Sources/LanguageServerProtocol/CMakeLists.txt b/Sources/LanguageServerProtocol/CMakeLists.txt index efe6b524f..03f55e21a 100644 --- a/Sources/LanguageServerProtocol/CMakeLists.txt +++ b/Sources/LanguageServerProtocol/CMakeLists.txt @@ -124,6 +124,7 @@ add_library(LanguageServerProtocol STATIC SupportTypes/SKCompletionOptions.swift SupportTypes/StringOrMarkupContent.swift SupportTypes/SymbolKind.swift + SupportTypes/TestItem.swift SupportTypes/TextDocumentContentChangeEvent.swift SupportTypes/TextDocumentEdit.swift SupportTypes/TextDocumentIdentifier.swift diff --git a/Sources/LanguageServerProtocol/Requests/DocumentTestsRequest.swift b/Sources/LanguageServerProtocol/Requests/DocumentTestsRequest.swift index 9445a42d5..c79dffac8 100644 --- a/Sources/LanguageServerProtocol/Requests/DocumentTestsRequest.swift +++ b/Sources/LanguageServerProtocol/Requests/DocumentTestsRequest.swift @@ -15,7 +15,7 @@ /// **(LSP Extension)** public struct DocumentTestsRequest: TextDocumentRequest, Hashable { public static let method: String = "textDocument/tests" - public typealias Response = [WorkspaceSymbolItem]? + public typealias Response = [TestItem] public var textDocument: TextDocumentIdentifier diff --git a/Sources/LanguageServerProtocol/Requests/WorkspaceTestsRequest.swift b/Sources/LanguageServerProtocol/Requests/WorkspaceTestsRequest.swift index e0f2ae311..843b645ef 100644 --- a/Sources/LanguageServerProtocol/Requests/WorkspaceTestsRequest.swift +++ b/Sources/LanguageServerProtocol/Requests/WorkspaceTestsRequest.swift @@ -15,7 +15,7 @@ /// **(LSP Extension)** public struct WorkspaceTestsRequest: RequestType, Hashable { public static let method: String = "workspace/tests" - public typealias Response = [WorkspaceSymbolItem]? + public typealias Response = [TestItem] public init() {} } diff --git a/Sources/LanguageServerProtocol/SupportTypes/TestItem.swift b/Sources/LanguageServerProtocol/SupportTypes/TestItem.swift new file mode 100644 index 000000000..b91ccba97 --- /dev/null +++ b/Sources/LanguageServerProtocol/SupportTypes/TestItem.swift @@ -0,0 +1,70 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 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 +// +//===----------------------------------------------------------------------===// + +public struct TestTag: Codable, Equatable, Sendable { + /// ID of the test tag. `TestTag` instances with the same ID are considered to be identical. + public let id: String + + public init(id: String) { + self.id = id + } +} + +/// A test item that can be shown an a client's test explorer or used to identify tests alongside a source file. +/// +/// A `TestItem` can represent either a test suite or a test itself, since they both have similar capabilities. +public struct TestItem: ResponseType, Equatable { + /// Identifier for the `TestItem`. + /// + /// This identifier uniquely identifies the test case or test suite. It can be used to run an individual test (suite). + public let id: String + + /// Display name describing the test. + public let label: String + + /// Optional description that appears next to the label. + public let description: String? + + /// A string that should be used when comparing this item with other items. + /// + /// When `nil` the `label` is used. + public let sortText: String? + + /// The location of the test item in the source code. + public let location: Location + + /// The children of this test item. + /// + /// For a test suite, this may contain the individual test cases or nested suites. + public let children: [TestItem] + + /// Tags associated with this test item. + public let tags: [TestTag] + + public init( + id: String, + label: String, + description: String? = nil, + sortText: String? = nil, + location: Location, + children: [TestItem], + tags: [TestTag] + ) { + self.id = id + self.label = label + self.description = description + self.sortText = sortText + self.location = location + self.children = children + self.tags = tags + } +} diff --git a/Sources/SKSupport/LineTable.swift b/Sources/SKSupport/LineTable.swift index 87f00e9f6..343e81f86 100644 --- a/Sources/SKSupport/LineTable.swift +++ b/Sources/SKSupport/LineTable.swift @@ -125,15 +125,50 @@ extension LineTable { } } -// MARK: - Position translation +// MARK: - Position conversion extension LineTable { // MARK: line:column <-> String.Index + /// Result of `lineSlice(at:)` + @usableFromInline + enum LineSliceResult { + /// The line index passed to `lineSlice(at:)` was negative. + case beforeFirstLine + /// The contents of the line at the index passed to `lineSlice(at:)`. + case line(Substring) + /// The line index passed to `lineSlice(at:)` was after the last line of the file + case afterLastLine + } + + /// Extracts the contents of the line at the given index. + /// + /// If `line` is out-of-bounds, logs a fault and returns either `beforeFirstLine` or `afterLastLine`. + @usableFromInline + func lineSlice(at line: Int, callerFile: StaticString, callerLine: UInt) -> LineSliceResult { + guard line >= 0 else { + logger.fault( + """ + Line \(line) is negative (\(callerFile, privacy: .public):\(callerLine, privacy: .public)) + """ + ) + return .beforeFirstLine + } + guard line < count else { + logger.fault( + """ + Line \(line) is out-of range (\(callerFile, privacy: .public):\(callerLine, privacy: .public)) + """ + ) + return .afterLastLine + } + return .line(self[line]) + } + /// Converts the given UTF-16-based `line:column`` position to a `String.Index`. /// - /// If the position does not refer to a valid position with in the source file, returns `nil` and logs a fault - /// containing the file and line of the caller (from `callerFile` and `callerLine`). + /// If the position does not refer to a valid position with in the source file, returns the closest valid position and + /// logs a fault containing the file and line of the caller (from `callerFile` and `callerLine`). /// /// - parameter line: Line number (zero-based). /// - parameter utf16Column: UTF-16 column offset (zero-based). @@ -143,34 +178,42 @@ extension LineTable { utf16Column: Int, callerFile: StaticString = #fileID, callerLine: UInt = #line - ) -> String.Index? { - guard line < count else { + ) -> String.Index { + let lineSlice: Substring + switch self.lineSlice(at: line, callerFile: callerFile, callerLine: callerLine) { + case .beforeFirstLine: + return self.content.startIndex + case .afterLastLine: + return self.content.endIndex + case .line(let line): + lineSlice = line + } + guard utf16Column >= 0 else { logger.fault( """ - Unable to get string index for \(line):\(utf16Column) (UTF-16) because line is out of range \ + Column is negative while converting \(line):\(utf16Column) (UTF-16) to String.Index \ (\(callerFile, privacy: .public):\(callerLine, privacy: .public)) """ ) - return nil + return lineSlice.startIndex } - let lineSlice = self[line] guard let index = content.utf16.index(lineSlice.startIndex, offsetBy: utf16Column, limitedBy: lineSlice.endIndex) else { logger.fault( """ - Unable to get string index for \(line):\(utf16Column) (UTF-16) because column is out of range \ + Column is past line end while converting \(line):\(utf16Column) (UTF-16) to String.Index \ (\(callerFile, privacy: .public):\(callerLine, privacy: .public)) """ ) - return nil + return lineSlice.endIndex } return index } /// Converts the given UTF-8-based `line:column`` position to a `String.Index`. /// - /// If the position does not refer to a valid position with in the source file, returns `nil` and logs a fault - /// containing the file and line of the caller (from `callerFile` and `callerLine`). + /// If the position does not refer to a valid position with in the source file, returns the closest valid position and + /// logs a fault containing the file and line of the caller (from `callerFile` and `callerLine`). /// /// - parameter line: Line number (zero-based). /// - parameter utf8Column: UTF-8 column offset (zero-based). @@ -180,35 +223,46 @@ extension LineTable { utf8Column: Int, callerFile: StaticString = #fileID, callerLine: UInt = #line - ) -> String.Index? { - guard 0 <= line, line < count else { + ) -> String.Index { + let lineSlice: Substring + switch self.lineSlice(at: line, callerFile: callerFile, callerLine: callerLine) { + case .beforeFirstLine: + return self.content.startIndex + case .afterLastLine: + return self.content.endIndex + case .line(let line): + lineSlice = line + } + + guard utf8Column >= 0 else { logger.fault( """ - Unable to get string index for \(line):\(utf8Column) (UTF-8) because line is out of range \ + Column is negative while converting \(line):\(utf8Column) (UTF-8) to String.Index. \ (\(callerFile, privacy: .public):\(callerLine, privacy: .public)) """ ) - return nil + return lineSlice.startIndex } - guard 0 <= utf8Column else { + guard let index = content.utf8.index(lineSlice.startIndex, offsetBy: utf8Column, limitedBy: lineSlice.endIndex) + else { logger.fault( """ - Unable to get string index for \(line):\(utf8Column) (UTF-8) because column is out of range \ + Column is after end of line while converting \(line):\(utf8Column) (UTF-8) to String.Index. \ (\(callerFile, privacy: .public):\(callerLine, privacy: .public)) """ ) - return nil + return lineSlice.endIndex } - let lineSlice = self[line] - return content.utf8.index(lineSlice.startIndex, offsetBy: utf8Column, limitedBy: lineSlice.endIndex) + + return index } // MARK: line:column <-> UTF-8 offset /// Converts the given UTF-16-based `line:column`` position to a UTF-8 offset within the source file. /// - /// If the position does not refer to a valid position with in the source file, returns `nil` and logs a fault - /// containing the file and line of the caller (from `callerFile` and `callerLine`). + /// If the position does not refer to a valid position with in the source file, returns the closest valid offset and + /// logs a fault containing the file and line of the caller (from `callerFile` and `callerLine`). /// /// - parameter line: Line number (zero-based). /// - parameter utf16Column: UTF-16 column offset (zero-based). @@ -218,24 +272,20 @@ extension LineTable { utf16Column: Int, callerFile: StaticString = #fileID, callerLine: UInt = #line - ) -> Int? { - guard - let stringIndex = stringIndexOf( - line: line, - utf16Column: utf16Column, - callerFile: callerFile, - callerLine: callerLine - ) - else { - return nil - } + ) -> Int { + let stringIndex = stringIndexOf( + line: line, + utf16Column: utf16Column, + callerFile: callerFile, + callerLine: callerLine + ) return content.utf8.distance(from: content.startIndex, to: stringIndex) } - /// Converts the given UTF-8-based `line:column`` position to a UTF-8 offset within the source file. + /// Converts the given UTF-8-based `line:column` position to a UTF-8 offset within the source file. /// - /// If the position does not refer to a valid position with in the source file, returns `nil` and logs a fault - /// containing the file and line of the caller (from `callerFile` and `callerLine`). + /// If the position does not refer to a valid position with in the source file, returns the closest valid offset and + /// logs a fault containing the file and line of the caller (from `callerFile` and `callerLine`). /// /// - parameter line: Line number (zero-based). /// - parameter utf8Column: UTF-8 column offset (zero-based). @@ -245,24 +295,20 @@ extension LineTable { utf8Column: Int, callerFile: StaticString = #fileID, callerLine: UInt = #line - ) -> Int? { - guard - let stringIndex = stringIndexOf( - line: line, - utf8Column: utf8Column, - callerFile: callerFile, - callerLine: callerLine - ) - else { - return nil - } + ) -> Int { + let stringIndex = stringIndexOf( + line: line, + utf8Column: utf8Column, + callerFile: callerFile, + callerLine: callerLine + ) return content.utf8.distance(from: content.startIndex, to: stringIndex) } - /// Converts the given UTF-16-based line:column position to the UTF-8 offset of that position within the source file. + /// Converts the given UTF-8 offset to a zero-based UTF-16 line:column pair. /// - /// If the position does not refer to a valid position with in the snapshot, returns `nil` and logs a fault - /// containing the file and line of the caller (from `callerFile` and `callerLine`). + /// If the position does not refer to a valid position with in the snapshot, returns the closest valid line:column + /// pair and logs a fault containing the file and line of the caller (from `callerFile` and `callerLine`). /// /// - parameter utf8Offset: UTF-8 buffer offset (zero-based). @inlinable @@ -270,47 +316,48 @@ extension LineTable { utf8Offset: Int, callerFile: StaticString = #fileID, callerLine: UInt = #line - ) -> (line: Int, utf16Column: Int)? { + ) -> (line: Int, utf16Column: Int) { + guard utf8Offset >= 0 else { + logger.fault( + """ + UTF-8 offset \(utf8Offset) is negative while converting it to UTF-16 line:column \ + (\(callerFile, privacy: .public):\(callerLine, privacy: .public)) + """ + ) + return (line: 0, utf16Column: 0) + } guard utf8Offset <= content.utf8.count else { logger.fault( """ - Unable to get line and UTF-16 column for UTF-8 offset \(utf8Offset) because offset is out of range \ + UTF-8 offset \(utf8Offset) is past the end of the file while converting it to UTF-16 line:column \ (\(callerFile, privacy: .public):\(callerLine, privacy: .public)) """ ) - return nil + return lineAndUTF16ColumnOf(content.endIndex) } return lineAndUTF16ColumnOf(content.utf8.index(content.startIndex, offsetBy: utf8Offset)) } /// Converts the given UTF-8-based line:column position to the UTF-8 offset of that position within the source file. /// - /// If the position does not refer to a valid position with in the snapshot, returns `nil` and logs a fault - /// containing the file and line of the caller (from `callerFile` and `callerLine`). + /// If the position does not refer to a valid position with in the snapshot, returns the closest valid line:colum pair + /// and logs a fault containing the file and line of the caller (from `callerFile` and `callerLine`). @inlinable func lineAndUTF8ColumnOf( utf8Offset: Int, callerFile: StaticString = #fileID, callerLine: UInt = #line - ) -> (line: Int, utf8Column: Int)? { - guard - let (line, utf16Column) = lineAndUTF16ColumnOf( - utf8Offset: utf8Offset, - callerFile: callerFile, - callerLine: callerLine - ) - else { - return nil - } - guard - let utf8Column = utf8ColumnAt( - line: line, - utf16Column: utf16Column, - callerFile: callerFile, - callerLine: callerLine - ) - else { - return nil - } + ) -> (line: Int, utf8Column: Int) { + let (line, utf16Column) = lineAndUTF16ColumnOf( + utf8Offset: utf8Offset, + callerFile: callerFile, + callerLine: callerLine + ) + let utf8Column = utf8ColumnAt( + line: line, + utf16Column: utf16Column, + callerFile: callerFile, + callerLine: callerLine + ) return (line, utf8Column) } @@ -318,8 +365,8 @@ extension LineTable { /// Returns UTF-16 column offset at UTF-8 based `line:column` position. /// - /// If the position does not refer to a valid position with in the snapshot, returns `nil` and logs a fault - /// containing the file and line of the caller (from `callerFile` and `callerLine`). + /// If the position does not refer to a valid position with in the snapshot, performs a best-effort recovery and logs + /// a fault containing the file and line of the caller (from `callerFile` and `callerLine`). /// /// - parameter line: Line number (zero-based). /// - parameter utf8Column: UTF-8 column offset (zero-based). @@ -329,21 +376,34 @@ extension LineTable { utf8Column: Int, callerFile: StaticString = #fileID, callerLine: UInt = #line - ) -> Int? { - return convertColumn( - line: line, - column: utf8Column, - indexFunction: content.utf8.index(_:offsetBy:limitedBy:), - distanceFunction: content.utf16.distance(from:to:), - callerFile: callerFile, - callerLine: callerLine - ) + ) -> Int { + let lineSlice: Substring + switch self.lineSlice(at: line, callerFile: callerFile, callerLine: callerLine) { + case .beforeFirstLine, .afterLastLine: + // This line is out-of-bounds. `lineSlice(at:)` already logged a fault. + // Recovery by assuming that UTF-8 and UTF-16 columns are similar. + return utf8Column + case .line(let line): + lineSlice = line + } + guard + let stringIndex = lineSlice.utf8.index(lineSlice.startIndex, offsetBy: utf8Column, limitedBy: lineSlice.endIndex) + else { + logger.fault( + """ + UTF-8 column is past the end of the line while getting UTF-16 column of \(line):\(utf8Column) \ + (\(callerFile, privacy: .public):\(callerLine, privacy: .public)) + """ + ) + return lineSlice.utf16.count + } + return lineSlice.utf16.distance(from: lineSlice.startIndex, to: stringIndex) } /// Returns UTF-8 column offset at UTF-16 based `line:column` position. /// - /// If the position does not refer to a valid position with in the snapshot, returns `nil` and logs a fault - /// containing the file and line of the caller (from `callerFile` and `callerLine`). + /// If the position does not refer to a valid position with in the snapshot, performs a bets-effort recovery and logs + /// a fault containing the file and line of the caller (from `callerFile` and `callerLine`). /// /// - parameter line: Line number (zero-based). /// - parameter utf16Column: UTF-16 column offset (zero-based). @@ -353,45 +413,31 @@ extension LineTable { utf16Column: Int, callerFile: StaticString = #fileID, callerLine: UInt = #line - ) -> Int? { - return convertColumn( - line: line, - column: utf16Column, - indexFunction: content.utf16.index(_:offsetBy:limitedBy:), - distanceFunction: content.utf8.distance(from:to:), - callerFile: callerFile, - callerLine: callerLine - ) - } - - @inlinable - func convertColumn( - line: Int, - column: Int, - indexFunction: (Substring.Index, Int, Substring.Index) -> Substring.Index?, - distanceFunction: (Substring.Index, Substring.Index) -> Int, - callerFile: StaticString = #fileID, - callerLine: UInt = #line - ) -> Int? { - guard line < count else { - logger.fault( - """ - Unable to convert column of \(line):\(column) because line is out of range \ - (\(callerFile, privacy: .public):\(callerLine, privacy: .public)) - """ - ) - return nil + ) -> Int { + let lineSlice: Substring + switch self.lineSlice(at: line, callerFile: callerFile, callerLine: callerLine) { + case .beforeFirstLine, .afterLastLine: + // This line is out-of-bounds. `lineSlice` already logged a fault. + // Recovery by assuming that UTF-8 and UTF-16 columns are similar. + return utf16Column + case .line(let line): + lineSlice = line } - let lineSlice = self[line] - guard let targetIndex = indexFunction(lineSlice.startIndex, column, lineSlice.endIndex) else { + guard + let stringIndex = lineSlice.utf16.index( + lineSlice.startIndex, + offsetBy: utf16Column, + limitedBy: lineSlice.endIndex + ) + else { logger.fault( """ - Unable to convert column of \(line):\(column) because column is out of range \ + UTF-16 column is past the end of the line while getting UTF-8 column of \(line):\(utf16Column) \ (\(callerFile, privacy: .public):\(callerLine, privacy: .public)) """ ) - return nil + return lineSlice.utf8.count } - return distanceFunction(lineSlice.startIndex, targetIndex) + return lineSlice.utf8.distance(from: lineSlice.startIndex, to: stringIndex) } } diff --git a/Sources/SKSwiftPMWorkspace/SwiftPMBuildSystem.swift b/Sources/SKSwiftPMWorkspace/SwiftPMBuildSystem.swift index fa40f225f..d4981712b 100644 --- a/Sources/SKSwiftPMWorkspace/SwiftPMBuildSystem.swift +++ b/Sources/SKSwiftPMWorkspace/SwiftPMBuildSystem.swift @@ -363,11 +363,9 @@ extension SwiftPMBuildSystem: SKCore.BuildSystem { public func filesDidChange(_ events: [FileEvent]) async { if events.contains(where: { self.fileEventShouldTriggerPackageReload(event: $0) }) { logger.log("Reloading package because of file change") - do { + await orLog("Reloading package") { // TODO: It should not be necessary to reload the entire package just to get build settings for one file. try await self.reloadPackage() - } catch { - logError(prefix: "Reloading package", error: error) } } } diff --git a/Sources/SKTestSupport/TestSourceKitLSPClient.swift b/Sources/SKTestSupport/TestSourceKitLSPClient.swift index d3ce52091..72fe9107d 100644 --- a/Sources/SKTestSupport/TestSourceKitLSPClient.swift +++ b/Sources/SKTestSupport/TestSourceKitLSPClient.swift @@ -357,9 +357,7 @@ public struct DocumentPositions { let lineTable = LineTable(textWithoutMarkers) positions = markers.mapValues { offset in - guard let (line, column) = lineTable.lineAndUTF16ColumnOf(utf8Offset: offset) else { - preconditionFailure("UTF-8 offset not within source file: \(offset)") - } + let (line, column) = lineTable.lineAndUTF16ColumnOf(utf8Offset: offset) return Position(line: line, utf16index: column) } } diff --git a/Sources/SourceKitLSP/DocumentManager.swift b/Sources/SourceKitLSP/DocumentManager.swift index 67d188eda..2cf11ef78 100644 --- a/Sources/SourceKitLSP/DocumentManager.swift +++ b/Sources/SourceKitLSP/DocumentManager.swift @@ -88,7 +88,6 @@ public final class DocumentManager { public enum Error: Swift.Error { case alreadyOpen(DocumentURI) case missingDocument(DocumentURI) - case failedToConvertPosition } let queue: DispatchQueue = DispatchQueue(label: "document-manager-queue") @@ -156,10 +155,7 @@ public final class DocumentManager { var sourceEdits: [SourceEdit] = [] for edit in edits { - guard let sourceEdit = SourceEdit(edit: edit, lineTableBeforeEdit: document.latestLineTable) else { - throw Error.failedToConvertPosition - } - sourceEdits.append(sourceEdit) + sourceEdits.append(SourceEdit(edit: edit, lineTableBeforeEdit: document.latestLineTable)) if let range = edit.range { document.latestLineTable.replace( @@ -234,20 +230,16 @@ fileprivate extension SourceEdit { /// /// Returns `nil` if the `TextDocumentContentChangeEvent` refers to line:column positions that don't exist in /// `LineTable`. - init?(edit: TextDocumentContentChangeEvent, lineTableBeforeEdit: LineTable) { + init(edit: TextDocumentContentChangeEvent, lineTableBeforeEdit: LineTable) { if let range = edit.range { - guard - let offset = lineTableBeforeEdit.utf8OffsetOf( - line: range.lowerBound.line, - utf16Column: range.lowerBound.utf16index - ), - let end = lineTableBeforeEdit.utf8OffsetOf( - line: range.upperBound.line, - utf16Column: range.upperBound.utf16index - ) - else { - return nil - } + let offset = lineTableBeforeEdit.utf8OffsetOf( + line: range.lowerBound.line, + utf16Column: range.lowerBound.utf16index + ) + let end = lineTableBeforeEdit.utf8OffsetOf( + line: range.upperBound.line, + utf16Column: range.upperBound.utf16index + ) self.init( range: AbsolutePosition(utf8Offset: offset).. [WorkspaceSymbolItem]? + func syntacticDocumentTests(for uri: DocumentURI) async throws -> [TestItem] /// Crash the language server. Should be used for crash recovery testing only. func _crash() async diff --git a/Sources/SourceKitLSP/Rename.swift b/Sources/SourceKitLSP/Rename.swift index eec33a699..43c6be799 100644 --- a/Sources/SourceKitLSP/Rename.swift +++ b/Sources/SourceKitLSP/Rename.swift @@ -186,12 +186,8 @@ fileprivate struct SyntacticRenamePiece { else { return nil } - guard - let start = snapshot.positionOf(zeroBasedLine: line - 1, utf8Column: column - 1), - let end = snapshot.positionOf(zeroBasedLine: endLine - 1, utf8Column: endColumn - 1) - else { - return nil - } + let start = snapshot.positionOf(zeroBasedLine: line - 1, utf8Column: column - 1) + let end = snapshot.positionOf(zeroBasedLine: endLine - 1, utf8Column: endColumn - 1) guard let kind = SyntacticRenamePieceKind(kind, values: values) else { return nil } @@ -270,23 +266,19 @@ private extension LineTable { /// /// If either the lower or upper bound of `range` do not refer to valid positions with in the snapshot, returns /// `nil` and logs a fault containing the file and line of the caller (from `callerFile` and `callerLine`). - subscript(range: Range, callerFile: StaticString = #fileID, callerLine: UInt = #line) -> Substring? { - guard - let start = self.stringIndexOf( - line: range.lowerBound.line, - utf16Column: range.lowerBound.utf16index, - callerFile: callerFile, - callerLine: callerLine - ), - let end = self.stringIndexOf( - line: range.upperBound.line, - utf16Column: range.upperBound.utf16index, - callerFile: callerFile, - callerLine: callerLine - ) - else { - return nil - } + subscript(range: Range, callerFile: StaticString = #fileID, callerLine: UInt = #line) -> Substring { + let start = self.stringIndexOf( + line: range.lowerBound.line, + utf16Column: range.lowerBound.utf16index, + callerFile: callerFile, + callerLine: callerLine + ) + let end = self.stringIndexOf( + line: range.upperBound.line, + utf16Column: range.upperBound.utf16index, + callerFile: callerFile, + callerLine: callerLine + ) return self.content[start.. String { - guard - let position = snapshot.position(of: symbolLocation), - let offset = snapshot.utf8Offset(of: position) - else { - throw NameTranslationError.cannotComputeOffset(symbolLocation) - } let req = sourcekitd.dictionary([ keys.request: sourcekitd.requests.nameTranslation, keys.sourceFile: snapshot.uri.pseudoPath, keys.compilerArgs: await self.buildSettings(for: snapshot.uri)?.compilerArgs as [SKDRequestValue]?, - keys.offset: offset, + keys.offset: snapshot.utf8Offset(of: snapshot.position(of: symbolLocation)), keys.nameKind: sourcekitd.values.nameObjc, ]) @@ -947,10 +923,7 @@ extension SwiftLanguageService { /// also be a closing ']' for subscripts or the end of a trailing closure. private func findFunctionLikeRange(of position: Position, in snapshot: DocumentSnapshot) async -> Range? { let tree = await self.syntaxTreeManager.syntaxTree(for: snapshot) - guard let absolutePosition = snapshot.absolutePosition(of: position) else { - return nil - } - guard let token = tree.token(at: absolutePosition) else { + guard let token = tree.token(at: snapshot.absolutePosition(of: position)) else { return nil } @@ -1018,11 +991,10 @@ extension SwiftLanguageService { break } - if let startToken, let endToken, - let startPosition = snapshot.position(of: startToken.positionAfterSkippingLeadingTrivia), - let endPosition = snapshot.position(of: endToken.endPositionBeforeTrailingTrivia) - { - return startPosition.. [TextEdit] { - guard let position = snapshot.absolutePosition(of: renameLocation) else { - return [] - } + let position = snapshot.absolutePosition(of: renameLocation) let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot) let token = syntaxTree.token(at: position) let parameterClause: FunctionParameterClauseSyntax? @@ -1192,11 +1162,8 @@ extension SwiftLanguageService { definitionLanguage: .swift ) - guard let parameterPosition = snapshot.position(of: parameter.positionAfterSkippingLeadingTrivia) else { - continue - } - let parameterRenameEdits = await orLog("Renaming parameter") { + let parameterPosition = snapshot.position(of: parameter.positionAfterSkippingLeadingTrivia) // Once we have lexical scope lookup in swift-syntax, this can be a purely syntactic rename. // We know that the parameters are variables and thus there can't be overloads that need to be resolved by the // type checker. @@ -1247,9 +1214,9 @@ extension SwiftLanguageService { // E.g. `func foo(a: Int)` becomes `func foo(_ a: Int)`. return TextEdit(range: piece.range, newText: " " + oldParameterName) } - if let original = snapshot.lineTable[piece.range], - case .named(let newParameterLabel) = newParameter, - newParameterLabel.trimmingCharacters(in: .whitespaces) == original.trimmingCharacters(in: .whitespaces) + if case .named(let newParameterLabel) = newParameter, + newParameterLabel.trimmingCharacters(in: .whitespaces) + == snapshot.lineTable[piece.range].trimmingCharacters(in: .whitespaces) { // We are changing the external parameter name to be the same one as the internal parameter name. The // internal name is thus no longer needed. Drop it. @@ -1336,17 +1303,18 @@ extension SwiftLanguageService { } return compoundRenameRange.pieces.compactMap { (piece) -> TextEdit? in if piece.kind == .baseName { - if let absolutePiecePosition = snapshot.absolutePosition(of: piece.range.lowerBound), - let firstNameToken = tree.token(at: absolutePiecePosition), + if let firstNameToken = tree.token(at: snapshot.absolutePosition(of: piece.range.lowerBound)), firstNameToken.keyPathInParent == \FunctionParameterSyntax.firstName, let parameterSyntax = firstNameToken.parent(as: FunctionParameterSyntax.self), - parameterSyntax.secondName == nil, // Should always be true because otherwise decl would be second name - let firstNameEndPos = snapshot.position(of: firstNameToken.endPositionBeforeTrailingTrivia) + parameterSyntax.secondName == nil // Should always be true because otherwise decl would be second name { // We are renaming a function parameter from inside the function body. // This should be a local rename and it shouldn't affect all the callers of the function. Introduce the new // name as a second name. - return TextEdit(range: firstNameEndPos.. [RenameLocation] { - return self.relatedIdentifiers.compactMap { - (relatedIdentifier) -> RenameLocation? in + return self.relatedIdentifiers.map { + (relatedIdentifier) -> RenameLocation in let position = relatedIdentifier.range.lowerBound - guard let utf8Column = snapshot.lineTable.utf8ColumnAt(line: position.line, utf16Column: position.utf16index) - else { - return nil - } + let utf8Column = snapshot.lineTable.utf8ColumnAt(line: position.line, utf16Column: position.utf16index) return RenameLocation(line: position.line + 1, utf8Column: utf8Column + 1, usage: relatedIdentifier.usage) } } diff --git a/Sources/SourceKitLSP/SourceKitLSPServer.swift b/Sources/SourceKitLSP/SourceKitLSPServer.swift index 6b6fbfc21..fa1979c9f 100644 --- a/Sources/SourceKitLSP/SourceKitLSPServer.swift +++ b/Sources/SourceKitLSP/SourceKitLSPServer.swift @@ -1317,8 +1317,8 @@ extension SourceKitLSPServer { semanticTokensProvider: semanticTokensOptions, inlayHintProvider: inlayHintOptions, experimental: .dictionary([ - "workspace/tests": .dictionary(["version": .int(1)]), - "textDocument/tests": .dictionary(["version": .int(1)]), + "workspace/tests": .dictionary(["version": .int(2)]), + "textDocument/tests": .dictionary(["version": .int(2)]), ]) ) } @@ -2039,14 +2039,14 @@ extension SourceKitLSPServer { private func indexToLSPCallHierarchyItem( symbol: Symbol, - moduleName: String?, + containerName: String?, location: Location ) -> CallHierarchyItem { CallHierarchyItem( name: symbol.name, kind: symbol.kind.asLspSymbolKind(), tags: nil, - detail: moduleName, + detail: containerName, uri: location.uri, range: location.range, selectionRange: location.range, @@ -2077,7 +2077,7 @@ extension SourceKitLSPServer { // Only return a single call hierarchy item. Returning multiple doesn't make sense because they will all have the // same USR (because we query them by USR) and will thus expand to the exact same call hierarchy. - var callHierarchyItems = usrs.compactMap { (usr) -> CallHierarchyItem? in + let callHierarchyItems = usrs.compactMap { (usr) -> CallHierarchyItem? in guard let definition = index.primaryDefinitionOrDeclarationOccurrence(ofUSR: usr) else { return nil } @@ -2086,7 +2086,7 @@ extension SourceKitLSPServer { } return self.indexToLSPCallHierarchyItem( symbol: definition.symbol, - moduleName: definition.location.moduleName, + containerName: definition.containerName, location: location ) }.sorted(by: { Location(uri: $0.uri, range: $0.range) < Location(uri: $1.uri, range: $1.range) }) @@ -2126,27 +2126,44 @@ extension SourceKitLSPServer { callableUsrs += index.occurrences(ofUSR: data.usr, roles: .overrideOf).flatMap { occurrence in occurrence.relations.filter { $0.roles.contains(.overrideOf) }.map(\.symbol.usr) } + // callOccurrences are all the places that any of the USRs in callableUsrs is called. + // We also load the `calledBy` roles to get the method that contains the reference to this call. let callOccurrences = callableUsrs.flatMap { index.occurrences(ofUSR: $0, roles: .calledBy) } - let calls = callOccurrences.flatMap { occurrence -> [CallHierarchyIncomingCall] in - guard let location = indexToLSPLocation(occurrence.location) else { - return [] + + // Maps functions that call a USR in `callableUSRs` to all the called occurrences of `callableUSRs` within the + // function. If a function `foo` calls `bar` multiple times, `callersToCalls[foo]` will contain two call + // `SymbolOccurrence`s. + // This way, we can group multiple calls to `bar` within `foo` to a single item with multiple `fromRanges`. + var callersToCalls: [Symbol: [SymbolOccurrence]] = [:] + + for call in callOccurrences { + // Callers are all `calledBy` relations of a call to a USR in `callableUsrs`, ie. all the functions that contain a + // call to a USR in callableUSRs. In practice, this should always be a single item. + let callers = call.relations.filter { $0.roles.contains(.calledBy) }.map(\.symbol) + for caller in callers { + callersToCalls[caller, default: []].append(call) } - return occurrence.relations.filter { $0.symbol.kind.isCallable } - .map { related in - // Resolve the caller's definition to find its location - let definition = index.primaryDefinitionOrDeclarationOccurrence(ofUSR: related.symbol.usr) - let definitionSymbolLocation = definition?.location - let definitionLocation = definitionSymbolLocation.flatMap(indexToLSPLocation) - - return CallHierarchyIncomingCall( - from: indexToLSPCallHierarchyItem( - symbol: related.symbol, - moduleName: definitionSymbolLocation?.moduleName, - location: definitionLocation ?? location // Use occurrence location as fallback - ), - fromRanges: [location.range] - ) - } + } + + let calls = callersToCalls.compactMap { (caller: Symbol, calls: [SymbolOccurrence]) -> CallHierarchyIncomingCall? in + // Resolve the caller's definition to find its location + let definition = index.primaryDefinitionOrDeclarationOccurrence(ofUSR: caller.usr) + let definitionSymbolLocation = definition?.location + let definitionLocation = definitionSymbolLocation.flatMap(indexToLSPLocation) + + let locations = calls.compactMap { indexToLSPLocation($0.location) }.sorted() + guard !locations.isEmpty else { + return nil + } + + return CallHierarchyIncomingCall( + from: indexToLSPCallHierarchyItem( + symbol: caller, + containerName: definition?.containerName, + location: definitionLocation ?? locations.first! + ), + fromRanges: locations.map(\.range) + ) } return calls.sorted(by: { $0.from.name < $1.from.name }) } @@ -2172,7 +2189,7 @@ extension SourceKitLSPServer { return CallHierarchyOutgoingCall( to: indexToLSPCallHierarchyItem( symbol: occurrence.symbol, - moduleName: definitionSymbolLocation?.moduleName, + containerName: definition?.containerName, location: definitionLocation ?? location // Use occurrence location as fallback ), fromRanges: [location.range] @@ -2455,27 +2472,25 @@ extension IndexSymbolKind { return .null } } - - var isCallable: Bool { - switch self { - case .function, .instanceMethod, .classMethod, .staticMethod, .constructor, .destructor, .conversionFunction: - return true - case .unknown, .module, .namespace, .namespaceAlias, .macro, .enum, .struct, .protocol, .extension, .union, - .typealias, .field, .enumConstant, .parameter, .using, .concept, .commentTag, .variable, .instanceProperty, - .class, .staticProperty, .classProperty: - return false - } - } } extension SymbolOccurrence { /// Get the name of the symbol that is a parent of this symbol, if one exists - func getContainerName() -> String? { + var containerName: String? { let containers = relations.filter { $0.roles.contains(.childOf) } if containers.count > 1 { logger.fault("Expected an occurrence to a child of at most one symbol, not multiple") } - return containers.sorted().first?.symbol.name + return containers.filter { + switch $0.symbol.kind { + case .module, .namespace, .enum, .struct, .class, .protocol, .extension, .union: + return true + case .unknown, .namespaceAlias, .macro, .typealias, .function, .variable, .field, .enumConstant, + .instanceMethod, .classMethod, .staticMethod, .instanceProperty, .classProperty, .staticProperty, .constructor, + .destructor, .conversionFunction, .parameter, .using, .concept, .commentTag: + return false + } + }.sorted().first?.symbol.name } } @@ -2543,7 +2558,7 @@ extension WorkspaceSymbolItem { kind: symbolOccurrence.symbol.kind.asLspSymbolKind(), deprecated: nil, location: symbolLocation, - containerName: symbolOccurrence.getContainerName() + containerName: symbolOccurrence.containerName ) ) } diff --git a/Sources/SourceKitLSP/Swift/AdjustPositionToStartOfIdentifier.swift b/Sources/SourceKitLSP/Swift/AdjustPositionToStartOfIdentifier.swift index 209f6a748..f77e6ec4c 100644 --- a/Sources/SourceKitLSP/Swift/AdjustPositionToStartOfIdentifier.swift +++ b/Sources/SourceKitLSP/Swift/AdjustPositionToStartOfIdentifier.swift @@ -53,10 +53,7 @@ extension SwiftLanguageService { in snapshot: DocumentSnapshot ) async -> Position { let tree = await self.syntaxTreeManager.syntaxTree(for: snapshot) - guard let swiftSyntaxPosition = snapshot.absolutePosition(of: position) else { - return position - } - let visitor = StartOfIdentifierFinder(position: swiftSyntaxPosition) + let visitor = StartOfIdentifierFinder(position: snapshot.absolutePosition(of: position)) visitor.walk(tree) if let resolvedPosition = visitor.resolvedPosition { return snapshot.position(of: resolvedPosition) ?? position diff --git a/Sources/SourceKitLSP/Swift/CodeCompletion.swift b/Sources/SourceKitLSP/Swift/CodeCompletion.swift index 3d913a2e4..fae12340c 100644 --- a/Sources/SourceKitLSP/Swift/CodeCompletion.swift +++ b/Sources/SourceKitLSP/Swift/CodeCompletion.swift @@ -21,21 +21,11 @@ extension SwiftLanguageService { let snapshot = try documentManager.latestSnapshot(req.textDocument.uri) let completionPos = await adjustPositionToStartOfIdentifier(req.position, in: snapshot) - - guard let offset = snapshot.utf8Offset(of: completionPos) else { - return CompletionList(isIncomplete: true, items: []) - } + let offset = snapshot.utf8Offset(of: completionPos) + let filterText = String(snapshot.text[snapshot.indexOf(utf8Offset: offset).. TextEdit? { + ) -> TextEdit { let textEditRangeStart: Position // Compute the TextEdit @@ -456,15 +440,7 @@ class CodeCompletionSession { assert(completionPos.line == requestPosition.line) // Construct a string index for the edit range start by subtracting the UTF-8 code units to erase from the completion position. let line = snapshot.lineTable[completionPos.line] - guard - let completionPosStringIndex = snapshot.lineTable.stringIndexOf( - line: completionPos.line, - utf16Column: completionPos.utf16index - ) - else { - return nil - } - let deletionStartStringIndex = line.utf8.index(completionPosStringIndex, offsetBy: -utf8CodeUnitsToErase) + let deletionStartStringIndex = line.utf8.index(snapshot.index(of: completionPos), offsetBy: -utf8CodeUnitsToErase) // Compute the UTF-16 offset of the deletion start range. If the start lies in a previous line, this will be negative let deletionStartUtf16Offset = line.utf16.distance(from: line.startIndex, to: deletionStartStringIndex) diff --git a/Sources/SourceKitLSP/Swift/CursorInfo.swift b/Sources/SourceKitLSP/Swift/CursorInfo.swift index a76f43677..e0f75b1d7 100644 --- a/Sources/SourceKitLSP/Swift/CursorInfo.swift +++ b/Sources/SourceKitLSP/Swift/CursorInfo.swift @@ -132,9 +132,7 @@ extension SwiftLanguageService { ) async throws -> (cursorInfo: [CursorInfo], refactorActions: [SemanticRefactorCommand]) { let snapshot = try documentManager.latestSnapshot(uri) - guard let offsetRange = snapshot.utf8OffsetRange(of: range) else { - throw CursorInfoError.invalidRange(range) - } + let offsetRange = snapshot.utf8OffsetRange(of: range) let keys = self.keys diff --git a/Sources/SourceKitLSP/Swift/Diagnostic.swift b/Sources/SourceKitLSP/Swift/Diagnostic.swift index eb0880604..b47e28d5b 100644 --- a/Sources/SourceKitLSP/Swift/Diagnostic.swift +++ b/Sources/SourceKitLSP/Swift/Diagnostic.swift @@ -61,12 +61,7 @@ extension CodeAction { init?(_ fixIt: FixIt, in snapshot: DocumentSnapshot) { var textEdits = [TextEdit]() for edit in fixIt.edits { - guard let startPosition = snapshot.position(of: edit.range.lowerBound), - let endPosition = snapshot.position(of: edit.range.upperBound) - else { - continue - } - textEdits.append(TextEdit(range: startPosition.. 0 || !replacement.isEmpty { // Snippets are only suppored in code completion. @@ -136,6 +129,8 @@ extension TextEdit { return nil } + let position = snapshot.positionOf(utf8Offset: utf8Offset) + let endPosition = snapshot.positionOf(utf8Offset: utf8Offset + length) self.init(range: position.. 0, utf8Column > 0 { - range = snapshot.positionOf(zeroBasedLine: line - 1, utf8Column: utf8Column - 1).map(Range.init) + range = Range(snapshot.positionOf(zeroBasedLine: line - 1, utf8Column: utf8Column - 1)) } else if let utf8Offset: Int = diag[keys.offset] { - range = snapshot.positionOf(utf8Offset: utf8Offset).map(Range.init) + range = Range(snapshot.positionOf(utf8Offset: utf8Offset)) } // If the diagnostic has a range associated with it that starts at the same location as the diagnostics position, use it to retrieve a proper range for the diagnostic, instead of just reporting a zero-length range. (diag[keys.ranges] as SKDResponseArray?)?.forEach { index, skRange in - if let utf8Offset: Int = skRange[keys.offset], - let start = snapshot.positionOf(utf8Offset: utf8Offset), - start == range?.lowerBound, - let length: Int = skRange[keys.length], - let end = snapshot.positionOf(utf8Offset: utf8Offset + length) - { - range = start.. [TextE // Map the offset-based edits to line-column based edits to be consumed by LSP return concurrentEdits.edits.compactMap { (edit) -> TextEdit? in - guard let (startLine, startColumn) = original.lineTable.lineAndUTF16ColumnOf(utf8Offset: edit.offset) else { - return nil - } - guard let (endLine, endColumn) = original.lineTable.lineAndUTF16ColumnOf(utf8Offset: edit.endOffset) else { - return nil - } + let (startLine, startColumn) = original.lineTable.lineAndUTF16ColumnOf(utf8Offset: edit.offset) + let (endLine, endColumn) = original.lineTable.lineAndUTF16ColumnOf(utf8Offset: edit.endOffset) guard let newText = String(bytes: edit.replacement, encoding: .utf8) else { logger.fault("Failed to get String from UTF-8 bytes \(edit.replacement)") return nil diff --git a/Sources/SourceKitLSP/Swift/DocumentSymbols.swift b/Sources/SourceKitLSP/Swift/DocumentSymbols.swift index 24454b797..0fe06cfc6 100644 --- a/Sources/SourceKitLSP/Swift/DocumentSymbols.swift +++ b/Sources/SourceKitLSP/Swift/DocumentSymbols.swift @@ -74,13 +74,8 @@ fileprivate final class DocumentSymbolsFinder: SyntaxAnyVisitor { if !self.range.overlaps(range) { return .skipChildren } - guard let rangeLowerBound = snapshot.position(of: range.lowerBound), - let rangeUpperBound = snapshot.position(of: range.upperBound), - let selectionLowerBound = snapshot.position(of: selection.lowerBound), - let selectionUpperBound = snapshot.position(of: selection.upperBound) - else { - return .skipChildren - } + let positionRange = snapshot.range(of: range) + let selectionPositionRange = snapshot.range(of: selection) // Record MARK comments on the node's leading and trailing trivia in `result` not as a child of `node`. visit(node.leadingTrivia, position: node.position) @@ -94,8 +89,8 @@ fileprivate final class DocumentSymbolsFinder: SyntaxAnyVisitor { DocumentSymbol( name: name, kind: symbolKind, - range: rangeLowerBound.. RelatedIdentifiersResponse { - guard let offset = snapshot.utf8Offset(of: position) else { - throw ResponseError.unknown("invalid position \(position)") - } - let skreq = sourcekitd.dictionary([ keys.request: requests.relatedIdents, keys.cancelOnSubsequentRequest: 0, - keys.offset: offset, + keys.offset: snapshot.utf8Offset(of: position), keys.sourceFile: snapshot.uri.pseudoPath, keys.includeNonEditableBaseNames: includeNonEditableBaseNames ? 1 : 0, keys.compilerArgs: await self.buildSettings(for: snapshot.uri)?.compilerArgs as [SKDRequestValue]?, @@ -90,17 +86,14 @@ extension SwiftLanguageService { var relatedIdentifiers: [RelatedIdentifier] = [] results.forEach { _, value in - if let offset: Int = value[keys.offset], - let start: Position = snapshot.positionOf(utf8Offset: offset), - let length: Int = value[keys.length], - let end: Position = snapshot.positionOf(utf8Offset: offset + length) - { - let usage = RenameLocation.Usage(value[keys.nameType], values) ?? .unknown - relatedIdentifiers.append( - RelatedIdentifier(range: start..) - - /// The given position failed to convert to UTF-8. - case failedToRetrieveOffset(Range) - /// The underlying sourcekitd request failed with the given error. case responseError(ResponseError) @@ -100,10 +94,6 @@ enum SemanticRefactoringError: Error { extension SemanticRefactoringError: CustomStringConvertible { var description: String { switch self { - case .invalidRange(let range): - return "failed to refactor due to invalid range: \(range)" - case .failedToRetrieveOffset(let range): - return "Failed to convert range to UTF-8 offset: \(range)" case .responseError(let error): return "\(error)" case .noEditsNeeded(let url): @@ -128,14 +118,9 @@ extension SwiftLanguageService { let uri = refactorCommand.textDocument.uri let snapshot = try self.documentManager.latestSnapshot(uri) - guard let offsetRange = snapshot.utf8OffsetRange(of: refactorCommand.positionRange) else { - throw SemanticRefactoringError.failedToRetrieveOffset(refactorCommand.positionRange) - } let line = refactorCommand.positionRange.lowerBound.line let utf16Column = refactorCommand.positionRange.lowerBound.utf16index - guard let utf8Column = snapshot.lineTable.utf8ColumnAt(line: line, utf16Column: utf16Column) else { - throw SemanticRefactoringError.invalidRange(refactorCommand.positionRange) - } + let utf8Column = snapshot.lineTable.utf8ColumnAt(line: line, utf16Column: utf16Column) let skreq = sourcekitd.dictionary([ keys.request: self.requests.semanticRefactoring, @@ -146,7 +131,7 @@ extension SwiftLanguageService { // LSP is zero based, but this request is 1 based. keys.line: line + 1, keys.column: utf8Column + 1, - keys.length: offsetRange.count, + keys.length: snapshot.utf8OffsetRange(of: refactorCommand.positionRange).count, keys.actionUID: self.sourcekitd.api.uid_get_from_cstr(refactorCommand.actionString)!, keys.compilerArgs: await self.buildSettings(for: snapshot.uri)?.compilerArgs as [SKDRequestValue]?, ]) diff --git a/Sources/SourceKitLSP/Swift/SemanticTokens.swift b/Sources/SourceKitLSP/Swift/SemanticTokens.swift index ccf8acc73..47782f9b6 100644 --- a/Sources/SourceKitLSP/Swift/SemanticTokens.swift +++ b/Sources/SourceKitLSP/Swift/SemanticTokens.swift @@ -107,14 +107,7 @@ extension SyntaxClassifiedRange { return SyntaxHighlightingTokens(tokens: []) } - guard - let start: Position = snapshot.positionOf(utf8Offset: self.offset), - let end: Position = snapshot.positionOf(utf8Offset: self.endOffset) - else { - return SyntaxHighlightingTokens(tokens: []) - } - - let multiLineRange = start.. String.Index? { + /// If the offset is out-of-bounds of the snapshot, returns the closest valid index and logs a fault containing the + /// file and line of the caller (from `callerFile` and `callerLine`). + func indexOf(utf8Offset: Int, callerFile: StaticString = #fileID, callerLine: UInt = #line) -> String.Index { + guard utf8Offset >= 0 else { + logger.fault( + """ + UTF-8 offset \(utf8Offset) is negative while converting it to String.Index \ + (\(callerFile, privacy: .public):\(callerLine, privacy: .public)) + """ + ) + return text.startIndex + } guard let index = text.utf8.index(text.startIndex, offsetBy: utf8Offset, limitedBy: text.endIndex) else { logger.fault( """ - Unable to get String index for UTF-8 offset \(utf8Offset) because offset is out of range \ + UTF-8 offset \(utf8Offset) is past end of file while converting it to String.Index \ (\(callerFile, privacy: .public):\(callerLine, privacy: .public)) """ ) - return nil + return text.endIndex } return index } - // MARK: Position <-> Raw UTF-8 + // MARK: Position <-> Raw UTF-8 offset /// Converts the given UTF-16-based line:column position to the UTF-8 offset of that position within the source file. /// - /// If `position` does not refer to a valid position with in the snapshot, returns `nil` and logs a fault - /// containing the file and line of the caller (from `callerFile` and `callerLine`). - func utf8Offset(of position: Position, callerFile: StaticString = #fileID, callerLine: UInt = #line) -> Int? { + /// If `position` does not refer to a valid position with in the snapshot, returns the offset of the closest valid + /// position and logs a fault containing the file and line of the caller (from `callerFile` and `callerLine`). + func utf8Offset(of position: Position, callerFile: StaticString = #fileID, callerLine: UInt = #line) -> Int { return lineTable.utf8OffsetOf( line: position.line, utf16Column: position.utf16index, @@ -970,75 +973,78 @@ extension DocumentSnapshot { ) } - // MARK: Position <-> String.Index - - /// Converts the given UTF-16-based `line:column`` position to a `String.Index`. + /// Converts the given UTF-8 offset to a UTF-16-based line:column position. /// - /// If `position` does not refer to a valid position with in the snapshot, returns `nil` and logs a fault - /// containing the file and line of the caller (from `callerFile` and `callerLine`). - func index(of position: Position, callerFile: StaticString = #fileID, callerLine: UInt = #line) -> String.Index? { - return lineTable.stringIndexOf( - line: position.line, - utf16Column: position.utf16index, + /// If the offset is after the end of the snapshot, returns `nil` and logs a fault containing the file and line of + /// the caller (from `callerFile` and `callerLine`). + func positionOf(utf8Offset: Int, callerFile: StaticString = #fileID, callerLine: UInt = #line) -> Position { + let (line, utf16Column) = lineTable.lineAndUTF16ColumnOf( + utf8Offset: utf8Offset, callerFile: callerFile, callerLine: callerLine ) + return Position(line: line, utf16index: utf16Column) } /// Converts the given UTF-16 based line:column range to a UTF-8 based offset range. /// - /// If either the lower or upper bound of `range` do not refer to valid positions with in the snapshot, returns - /// `nil` and logs a fault containing the file and line of the caller (from `callerFile` and `callerLine`). + /// If the bounds of the range do not refer to a valid positions with in the snapshot, this function adjusts them to + /// the closest valid positions and logs a fault containing the file and line of the caller (from `callerFile` and + /// `callerLine`). func utf8OffsetRange( of range: Range, callerFile: StaticString = #fileID, callerLine: UInt = #line - ) -> Range? { - guard let startOffset = utf8Offset(of: range.lowerBound, callerFile: callerFile, callerLine: callerLine), - let endOffset = utf8Offset(of: range.upperBound, callerFile: callerFile, callerLine: callerLine) - else { - return nil - } + ) -> Range { + let startOffset = utf8Offset(of: range.lowerBound, callerFile: callerFile, callerLine: callerLine) + let endOffset = utf8Offset(of: range.upperBound, callerFile: callerFile, callerLine: callerLine) return startOffset.. String.Index + + /// Converts the given UTF-16-based `line:column` position to a `String.Index`. /// - /// If the offset is after the end of the snapshot, returns `nil` and logs a fault containing the file and line of - /// the caller (from `callerFile` and `callerLine`). - func positionOf(utf8Offset: Int, callerFile: StaticString = #fileID, callerLine: UInt = #line) -> Position? { - guard - let (line, utf16Column) = lineTable.lineAndUTF16ColumnOf( - utf8Offset: utf8Offset, - callerFile: callerFile, - callerLine: callerLine - ) - else { - return nil - } - return Position(line: line, utf16index: utf16Column) + /// If `position` does not refer to a valid position with in the snapshot, returns the index of the closest valid + /// position and logs a fault containing the file and line of the caller (from `callerFile` and `callerLine`). + func index(of position: Position, callerFile: StaticString = #fileID, callerLine: UInt = #line) -> String.Index { + return lineTable.stringIndexOf( + line: position.line, + utf16Column: position.utf16index, + callerFile: callerFile, + callerLine: callerLine + ) + } + + /// Converts the given UTF-16-based `line:column` range to a `String.Index` range. + /// + /// If the bounds of the range do not refer to a valid positions with in the snapshot, this function adjusts them to + /// the closest valid positions and logs a fault containing the file and line of the caller (from `callerFile` and + /// `callerLine`). + func indexRange( + of range: Range, + callerFile: StaticString = #fileID, + callerLine: UInt = #line + ) -> Range { + return self.index(of: range.lowerBound).. Position? { - guard - let utf16Column = lineTable.utf16ColumnAt( - line: zeroBasedLine, - utf8Column: utf8Column, - callerFile: callerFile, - callerLine: callerLine - ) - else { - return nil - } + ) -> Position { + let utf16Column = lineTable.utf16ColumnAt( + line: zeroBasedLine, + utf8Column: utf8Column, + callerFile: callerFile, + callerLine: callerLine + ) return Position(line: zeroBasedLine, utf16index: utf16Column) } @@ -1046,61 +1052,56 @@ extension DocumentSnapshot { /// Converts the given UTF-8-offset-based `AbsolutePosition` to a UTF-16-based line:column. /// - /// If the `AbsolutePosition` out of bounds of the source file, returns `nil` and logs a fault containing the file and - /// line of the caller (from `callerFile` and `callerLine`). + /// If the `AbsolutePosition` out of bounds of the source file, returns the closest valid position and logs a fault + /// containing the file and line of the caller (from `callerFile` and `callerLine`). func position( of position: AbsolutePosition, callerFile: StaticString = #fileID, callerLine: UInt = #line - ) -> Position? { + ) -> Position { return positionOf(utf8Offset: position.utf8Offset, callerFile: callerFile, callerLine: callerLine) } /// Converts the given UTF-16-based line:column `Position` to a UTF-8-offset-based `AbsolutePosition`. /// - /// If the UTF-16 based line:column pair does not refer to a valid position within the snapshot, returns `nil` and - /// logs a fault containing the file and line of the caller (from `callerFile` and `callerLine`). + /// If the UTF-16 based line:column pair does not refer to a valid position within the snapshot, returns the closest + /// valid position and logs a fault containing the file and line of the caller (from `callerFile` and `callerLine`). func absolutePosition( of position: Position, callerFile: StaticString = #fileID, callerLine: UInt = #line - ) -> AbsolutePosition? { - guard let offset = utf8Offset(of: position, callerFile: callerFile, callerLine: callerLine) else { - return nil - } + ) -> AbsolutePosition { + let offset = utf8Offset(of: position, callerFile: callerFile, callerLine: callerLine) return AbsolutePosition(utf8Offset: offset) } /// Converts the lower and upper bound of the given UTF-8-offset-based `AbsolutePosition` range to a UTF-16-based /// line:column range for use in LSP. /// - /// If either the lower or the upper bound of the range is out of bounds of the source file, returns `nil` and logs - /// a fault containing the file and line of the caller (from `callerFile` and `callerLine`). + /// If the bounds of the range do not refer to a valid positions with in the snapshot, this function adjusts them to + /// the closest valid positions and logs a fault containing the file and line of the caller (from `callerFile` and + /// `callerLine`). func range( of range: Range, callerFile: StaticString = #fileID, callerLine: UInt = #line - ) -> Range? { - guard let lowerBound = self.position(of: range.lowerBound, callerFile: callerFile, callerLine: callerLine), - let upperBound = self.position(of: range.upperBound, callerFile: callerFile, callerLine: callerLine) - else { - return nil - } + ) -> Range { + let lowerBound = self.position(of: range.lowerBound, callerFile: callerFile, callerLine: callerLine) + let upperBound = self.position(of: range.upperBound, callerFile: callerFile, callerLine: callerLine) return lowerBound.., callerFile: StaticString = #fileID, callerLine: UInt = #line - ) -> ByteSourceRange? { - guard let utf8OffsetRange = utf8OffsetRange(of: range, callerFile: callerFile, callerLine: callerLine) else { - return nil - } + ) -> ByteSourceRange { + let utf8OffsetRange = utf8OffsetRange(of: range, callerFile: callerFile, callerLine: callerLine) return ByteSourceRange(offset: utf8OffsetRange.startIndex, length: utf8OffsetRange.count) } @@ -1108,13 +1109,13 @@ extension DocumentSnapshot { /// Converts the given UTF-8-based line:column `RenamedLocation` to a UTF-16-based line:column `Position`. /// - /// If the UTF-8 based line:column pair does not refer to a valid position within the snapshot, returns `nil` and - /// logs a fault containing the file and line of the caller (from `callerFile` and `callerLine`). + /// If the UTF-8 based line:column pair does not refer to a valid position within the snapshot, returns the closest + /// valid position and logs a fault containing the file and line of the caller (from `callerFile` and `callerLine`). func position( of renameLocation: RenameLocation, callerFile: StaticString = #fileID, callerLine: UInt = #line - ) -> Position? { + ) -> Position { return positionOf( zeroBasedLine: renameLocation.line - 1, utf8Column: renameLocation.utf8Column - 1, @@ -1127,13 +1128,13 @@ extension DocumentSnapshot { /// Converts the given UTF-8-offset-based `SymbolLocation` to a UTF-16-based line:column `Position`. /// - /// If the UTF-8 offset is out-of-bounds of the snapshot, returns `nil` and logs a fault containing the file and line - /// of the caller (from `callerFile` and `callerLine`). + /// If the UTF-8 offset is out-of-bounds of the snapshot, returns the closest valid position and logs a fault + /// containing the file and line of the caller (from `callerFile` and `callerLine`). func position( of symbolLocation: SymbolLocation, callerFile: StaticString = #fileID, callerLine: UInt = #line - ) -> Position? { + ) -> Position { return positionOf( zeroBasedLine: symbolLocation.line - 1, utf8Column: symbolLocation.utf8Column - 1, @@ -1146,23 +1147,20 @@ extension DocumentSnapshot { /// Converts the given UTF-8-based line:column `RenamedLocation` to a UTF-8-offset-based `AbsolutePosition`. /// - /// If the UTF-8 based line:column pair does not refer to a valid position within the snapshot, returns `nil` and - /// logs a fault containing the file and line of the caller (from `callerFile` and `callerLine`). + /// If the UTF-8 based line:column pair does not refer to a valid position within the snapshot, returns the offset of + /// the closest valid position and logs a fault containing the file and line of the caller (from `callerFile` and + /// `callerLine`). func absolutePosition( of renameLocation: RenameLocation, callerFile: StaticString = #fileID, callerLine: UInt = #line - ) -> AbsolutePosition? { - guard - let utf8Offset = lineTable.utf8OffsetOf( - line: renameLocation.line - 1, - utf8Column: renameLocation.utf8Column - 1, - callerFile: callerFile, - callerLine: callerLine - ) - else { - return nil - } + ) -> AbsolutePosition { + let utf8Offset = lineTable.utf8OffsetOf( + line: renameLocation.line - 1, + utf8Column: renameLocation.utf8Column - 1, + callerFile: callerFile, + callerLine: callerLine + ) return AbsolutePosition(utf8Offset: utf8Offset) } } diff --git a/Sources/SourceKitLSP/Swift/SyntaxHighlightingTokenParser.swift b/Sources/SourceKitLSP/Swift/SyntaxHighlightingTokenParser.swift index 1b05cb57e..6733a7777 100644 --- a/Sources/SourceKitLSP/Swift/SyntaxHighlightingTokenParser.swift +++ b/Sources/SourceKitLSP/Swift/SyntaxHighlightingTokenParser.swift @@ -31,7 +31,6 @@ struct SyntaxHighlightingTokenParser { if let offset: Int = response[keys.offset], var length: Int = response[keys.length], - let start: Position = snapshot.positionOf(utf8Offset: offset), let skKind: sourcekitd_api_uid_t = response[keys.kind], case (let kind, var modifiers)? = parseKindAndModifiers(skKind) { @@ -39,7 +38,7 @@ struct SyntaxHighlightingTokenParser { // If the name is escaped in backticks, we need to add two characters to the // length for the backticks. if modifiers.contains(.declaration), - let index = snapshot.indexOf(utf8Offset: offset), snapshot.text[index] == "`" + snapshot.text[snapshot.indexOf(utf8Offset: offset)] == "`" { length += 2 } @@ -48,17 +47,15 @@ struct SyntaxHighlightingTokenParser { modifiers.insert(.defaultLibrary) } - if let end: Position = snapshot.positionOf(utf8Offset: offset + length) { - let multiLineRange = start.. { return [self] } - guard let startIndex = snapshot.index(of: lowerBound), - let endIndex = snapshot.index(of: upperBound) - else { - logger.fault("Range \(self) reaches outside of the document") - return [self] - } - - let text = snapshot.text[startIndex.. Range? { + guard case .documentSymbols(let documentSymbols) = documentSymbolsResponse else { + // Both `ClangLanguageService` and `SwiftLanguageService` return `documentSymbols` so we don't need to handle the + // .symbolInformation case. + logger.fault( + """ + Expected documentSymbols response from language service to resolve test ranges but got \ + \(documentSymbolsResponse.forLogging) + """ + ) + return nil + } + for documentSymbol in documentSymbols where documentSymbol.range.contains(position) { + if let children = documentSymbol.children, + let rangeOfChild = findInnermostSymbolRange(containing: position, documentSymbols: .documentSymbols(children)) + { + // If a child contains the position, prefer that because it's more specific. + return rangeOfChild + } + return documentSymbol.range + } + return nil +} + extension SourceKitLSPServer { - func workspaceTests(_ req: WorkspaceTestsRequest) async throws -> [WorkspaceSymbolItem]? { - let testSymbols = workspaces.flatMap { (workspace) -> [SymbolOccurrence] in - return workspace.index?.unitTests() ?? [] + /// Converts a flat list of test symbol occurrences to a hierarchical `TestItem` array, inferring the hierarchical + /// structure from `childOf` relations between the symbol occurrences. + /// + /// `resolvePositions` resolves the position of a test to a `Location` that is effectively a range. This allows us to + /// provide ranges for the test cases in source code instead of only the test's location that we get from the index. + private func testItems( + for testSymbolOccurrences: [SymbolOccurrence], + resolveLocation: (DocumentURI, Position) -> Location + ) -> [TestItem] { + // Arrange tests by the USR they are contained in. This allows us to emit test methods as children of test classes. + // `occurrencesByParent[nil]` are the root test symbols that aren't a child of another test symbol. + var occurrencesByParent: [String?: [SymbolOccurrence]] = [:] + + let testSymbolUsrs = Set(testSymbolOccurrences.map(\.symbol.usr)) + + for testSymbolOccurrence in testSymbolOccurrences { + let childOfUsrs = testSymbolOccurrence.relations + .filter { $0.roles.contains(.childOf) } + .map(\.symbol.usr) + .filter { testSymbolUsrs.contains($0) } + if childOfUsrs.count > 1 { + logger.fault( + "Test symbol \(testSymbolOccurrence.symbol.usr) is child or multiple symbols: \(childOfUsrs.joined(separator: ", "))" + ) + } + occurrencesByParent[childOfUsrs.sorted().first, default: []].append(testSymbolOccurrence) } - return - testSymbols - .filter { $0.canBeTestDefinition } + + /// Returns a test item for the given `testSymbolOccurrence`. + /// + /// Also includes test items for all tests that are children of this test. + /// + /// `context` is used to build the test's ID. It is an array containing the names of all parent symbols. These will + /// be joined with the test symbol's name using `/` to form the test ID. The test ID can be used to run an + /// individual test. + func testItem( + for testSymbolOccurrence: SymbolOccurrence, + documentManager: DocumentManager, + context: [String] + ) -> TestItem { + let symbolPosition: Position + if let snapshot = try? documentManager.latestSnapshot( + DocumentURI(URL(fileURLWithPath: testSymbolOccurrence.location.path)) + ) { + symbolPosition = snapshot.position(of: testSymbolOccurrence.location) + } else { + // Technically, we always need to convert UTF-8 columns to UTF-16 columns, which requires reading the file. + // In practice, they are almost always the same. + // We chose to avoid hitting the file system even if it means that we might report an incorrect column. + symbolPosition = Position( + line: testSymbolOccurrence.location.line - 1, // 1-based -> 0-based + utf16index: testSymbolOccurrence.location.utf8Column - 1 + ) + } + let id = (context + [testSymbolOccurrence.symbol.name]).joined(separator: "/") + let uri = DocumentURI(URL(fileURLWithPath: testSymbolOccurrence.location.path)) + let location = resolveLocation(uri, symbolPosition) + + let children = + occurrencesByParent[testSymbolOccurrence.symbol.usr, default: []] + .sorted() + .map { + testItem(for: $0, documentManager: documentManager, context: context + [testSymbolOccurrence.symbol.name]) + } + return TestItem( + id: id, + label: testSymbolOccurrence.symbol.name, + location: location, + children: children, + tags: [] + ) + } + + return occurrencesByParent[nil, default: []] .sorted() - .map(WorkspaceSymbolItem.init) + .map { testItem(for: $0, documentManager: documentManager, context: []) } + } + + func workspaceTests(_ req: WorkspaceTestsRequest) async throws -> [TestItem] { + // Gather all tests classes and test methods. + let testSymbolOccurrences = + workspaces + .flatMap { $0.index?.unitTests() ?? [] } + .filter { $0.canBeTestDefinition } + return testItems( + for: testSymbolOccurrences, + resolveLocation: { uri, position in Location(uri: uri, range: Range(position)) } + ) + } + + /// Extracts a flat dictionary mapping test IDs to their locations from the given `testItems`. + private func testLocations(from testItems: [TestItem]) -> [String: Location] { + var result: [String: Location] = [:] + for testItem in testItems { + result[testItem.id] = testItem.location + result.merge(testLocations(from: testItem.children)) { old, new in new } + } + return result } func documentTests( _ req: DocumentTestsRequest, workspace: Workspace, languageService: LanguageService - ) async throws -> [WorkspaceSymbolItem]? { + ) async throws -> [TestItem] { let snapshot = try self.documentManager.latestSnapshot(req.textDocument.uri) let mainFileUri = await workspace.buildSystemManager.mainFile( for: req.textDocument.uri, language: snapshot.language ) + if let index = workspace.index { var outOfDateChecker = IndexOutOfDateChecker() let testSymbols = @@ -62,7 +180,21 @@ extension SourceKitLSPServer { .filter { $0.canBeTestDefinition && outOfDateChecker.isUpToDate($0.location) } if !testSymbols.isEmpty { - return testSymbols.sorted().map(WorkspaceSymbolItem.init) + let documentSymbols = await orLog("Getting document symbols for test ranges") { + try await languageService.documentSymbol(DocumentSymbolRequest(textDocument: req.textDocument)) + } + + return testItems( + for: testSymbols, + resolveLocation: { uri, position in + if uri == snapshot.uri, let documentSymbols, + let range = findInnermostSymbolRange(containing: position, documentSymbols: documentSymbols) + { + return Location(uri: uri, range: range) + } + return Location(uri: uri, range: Range(position)) + } + ) } if outOfDateChecker.indexHasUpToDateUnit(for: mainFileUri.pseudoPath, index: index) { // The index is up-to-date and doesn't contain any tests. We don't need to do a syntactic fallback. @@ -83,7 +215,7 @@ private final class SyntacticSwiftXCTestScanner: SyntaxVisitor { private var snapshot: DocumentSnapshot /// The workspace symbols representing the found `XCTestCase` subclasses and test methods. - private var result: [WorkspaceSymbolItem] = [] + private var result: [TestItem] = [] /// Names of classes that are known to not inherit from `XCTestCase` and can thus be ruled out to be test classes. private static let knownNonXCTestSubclasses = ["NSObject"] @@ -96,15 +228,15 @@ private final class SyntacticSwiftXCTestScanner: SyntaxVisitor { public static func findTestSymbols( in snapshot: DocumentSnapshot, syntaxTreeManager: SyntaxTreeManager - ) async -> [WorkspaceSymbolItem] { + ) async -> [TestItem] { let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot) let visitor = SyntacticSwiftXCTestScanner(snapshot: snapshot) visitor.walk(syntaxTree) return visitor.result } - private func findTestMethods(in members: MemberBlockItemListSyntax, containerName: String) -> [WorkspaceSymbolItem] { - return members.compactMap { (member) -> WorkspaceSymbolItem? in + private func findTestMethods(in members: MemberBlockItemListSyntax, containerName: String) -> [TestItem] { + return members.compactMap { (member) -> TestItem? in guard let function = member.decl.as(FunctionDeclSyntax.self) else { return nil } @@ -116,22 +248,23 @@ private final class SyntacticSwiftXCTestScanner: SyntaxVisitor { // Test methods can't be static. return nil } - guard function.signature.returnClause == nil else { - // Test methods can't have a return type. + guard function.signature.returnClause == nil, function.signature.parameterClause.parameters.isEmpty else { + // Test methods can't have a return type or have parameters. // Technically we are also filtering out functions that have an explicit `Void` return type here but such // declarations are probably less common than helper functions that start with `test` and have a return type. return nil } - guard let position = snapshot.position(of: function.name.positionAfterSkippingLeadingTrivia) else { - return nil - } - let symbolInformation = SymbolInformation( - name: function.name.text, - kind: .method, - location: Location(uri: snapshot.uri, range: Range(position)), - containerName: containerName + let range = snapshot.range( + of: function.positionAfterSkippingLeadingTrivia.. [WorkspaceSymbolItem]? { + public func syntacticDocumentTests(for uri: DocumentURI) async throws -> [TestItem] { let snapshot = try documentManager.latestSnapshot(uri) return await SyntacticSwiftXCTestScanner.findTestSymbols(in: snapshot, syntaxTreeManager: syntaxTreeManager) } } extension ClangLanguageService { - public func syntacticDocumentTests(for uri: DocumentURI) async -> [WorkspaceSymbolItem]? { - return nil + public func syntacticDocumentTests(for uri: DocumentURI) async -> [TestItem] { + return [] } } diff --git a/Tests/SKSupportTests/SupportTests.swift b/Tests/SKSupportTests/SupportTests.swift index c8856a52e..397d4a931 100644 --- a/Tests/SKSupportTests/SupportTests.swift +++ b/Tests/SKSupportTests/SupportTests.swift @@ -76,24 +76,18 @@ final class SupportTests: XCTestCase { func checkLineAndColumns( _ table: LineTable, _ utf8Offset: Int, - _ expected: (line: Int, utf16Column: Int)?, + _ expected: (line: Int, utf16Column: Int), file: StaticString = #filePath, line: UInt = #line ) { - switch (table.lineAndUTF16ColumnOf(utf8Offset: utf8Offset), expected) { - case (nil, nil): - break - case (let result?, let _expected?): - XCTAssertTrue(result == _expected, "\(result) != \(_expected)", file: file, line: line) - case (let result, let _expected): - XCTFail("\(String(describing: result)) != \(String(describing: _expected))", file: file, line: line) - } + let actual = table.lineAndUTF16ColumnOf(utf8Offset: utf8Offset) + XCTAssert(actual == expected, "\(actual) != \(expected)", file: file, line: line) } func checkUTF8OffsetOf( _ table: LineTable, _ query: (line: Int, utf16Column: Int), - _ expected: Int?, + _ expected: Int, file: StaticString = #filePath, line: UInt = #line ) { @@ -108,7 +102,7 @@ final class SupportTests: XCTestCase { func checkUTF16ColumnAt( _ table: LineTable, _ query: (line: Int, utf8Column: Int), - _ expected: Int?, + _ expected: Int, file: StaticString = #filePath, line: UInt = #line ) { @@ -135,25 +129,25 @@ final class SupportTests: XCTestCase { checkLineAndColumns(t1, 9, (line: 1, utf16Column: 4)) checkLineAndColumns(t1, 10, (line: 2, utf16Column: 0)) checkLineAndColumns(t1, 14, (line: 2, utf16Column: 4)) - checkLineAndColumns(t1, 15, nil) + checkLineAndColumns(t1, 15, (line: 2, utf16Column: 4)) checkUTF8OffsetOf(t1, (line: 0, utf16Column: 0), 0) checkUTF8OffsetOf(t1, (line: 0, utf16Column: 2), 2) checkUTF8OffsetOf(t1, (line: 0, utf16Column: 4), 4) checkUTF8OffsetOf(t1, (line: 0, utf16Column: 5), 5) - checkUTF8OffsetOf(t1, (line: 0, utf16Column: 6), nil) + checkUTF8OffsetOf(t1, (line: 0, utf16Column: 6), 5) // Recovers to end of line 0 checkUTF8OffsetOf(t1, (line: 1, utf16Column: 0), 5) checkUTF8OffsetOf(t1, (line: 1, utf16Column: 4), 9) checkUTF8OffsetOf(t1, (line: 2, utf16Column: 0), 10) checkUTF8OffsetOf(t1, (line: 2, utf16Column: 4), 14) - checkUTF8OffsetOf(t1, (line: 2, utf16Column: 5), nil) - checkUTF8OffsetOf(t1, (line: 3, utf16Column: 0), nil) + checkUTF8OffsetOf(t1, (line: 2, utf16Column: 5), 14) // Recovers to end of line 2 + checkUTF8OffsetOf(t1, (line: 3, utf16Column: 0), 14) // Recovers to end of source file checkUTF16ColumnAt(t1, (line: 0, utf8Column: 4), 4) checkUTF16ColumnAt(t1, (line: 0, utf8Column: 5), 5) - checkUTF16ColumnAt(t1, (line: 0, utf8Column: 6), nil) + checkUTF16ColumnAt(t1, (line: 0, utf8Column: 6), 5) // Recovers to end of line 0 checkUTF16ColumnAt(t1, (line: 2, utf8Column: 0), 0) - checkUTF16ColumnAt(t1, (line: 3, utf8Column: 0), nil) + checkUTF16ColumnAt(t1, (line: 3, utf8Column: 0), 0) // Line out of bounds, so keeps column let t2 = LineTable( """ @@ -174,11 +168,11 @@ final class SupportTests: XCTestCase { checkUTF8OffsetOf(t2, (line: 0, utf16Column: 6), 16) checkUTF8OffsetOf(t2, (line: 1, utf16Column: 1), 19) checkUTF8OffsetOf(t2, (line: 1, utf16Column: 6), 32) - checkUTF8OffsetOf(t2, (line: 1, utf16Column: 7), nil) + checkUTF8OffsetOf(t2, (line: 1, utf16Column: 7), 32) // Recovers to end of line 1 checkUTF8OffsetOf(t2, (line: 2, utf16Column: 0), 32) checkUTF8OffsetOf(t2, (line: 2, utf16Column: 2), 36) checkUTF8OffsetOf(t2, (line: 2, utf16Column: 4), 40) - checkUTF8OffsetOf(t2, (line: 2, utf16Column: 5), nil) + checkUTF8OffsetOf(t2, (line: 2, utf16Column: 5), 40) // Recovers to end of line 2 checkUTF16ColumnAt(t2, (line: 0, utf8Column: 3), 1) checkUTF16ColumnAt(t2, (line: 0, utf8Column: 15), 5) diff --git a/Tests/SourceKitLSPTests/CallHierarchyTests.swift b/Tests/SourceKitLSPTests/CallHierarchyTests.swift index dce35c7e0..af69f0ffd 100644 --- a/Tests/SourceKitLSPTests/CallHierarchyTests.swift +++ b/Tests/SourceKitLSPTests/CallHierarchyTests.swift @@ -90,7 +90,7 @@ final class CallHierarchyTests: XCTestCase { func item( _ name: String, _ kind: SymbolKind, - detail: String = "test", + detail: String? = nil, usr: String, at position: Position ) -> CallHierarchyItem { @@ -215,7 +215,7 @@ final class CallHierarchyTests: XCTestCase { name: "foo", kind: .method, tags: nil, - detail: "", + detail: "FilePathIndex", uri: try project.uri(for: "lib.cpp"), range: try Range(project.position(of: "2️⃣", in: "lib.cpp")), selectionRange: try Range(project.position(of: "2️⃣", in: "lib.cpp")), @@ -255,7 +255,7 @@ final class CallHierarchyTests: XCTestCase { name: "testFunc(x:)", kind: .function, tags: nil, - detail: "test", // test is the module name because the file is called test.swift + detail: nil, uri: project.fileURI, range: Range(project.positions["2️⃣"]), selectionRange: Range(project.positions["2️⃣"]), @@ -275,9 +275,15 @@ final class CallHierarchyTests: XCTestCase { """ func 1️⃣foo() {} - var testVar: Int 2️⃣{ - let myVar = 3️⃣foo() - return 2 + var testVar: Int { + 2️⃣get { + let myVar = 3️⃣foo() + return 2 + } + } + + func 4️⃣testFunc() { + _ = 5️⃣testVar } """ ) @@ -297,7 +303,7 @@ final class CallHierarchyTests: XCTestCase { name: "getter:testVar", kind: .function, tags: nil, - detail: "test", // test is the module name because the file is called test.swift + detail: nil, uri: project.fileURI, range: Range(project.positions["2️⃣"]), selectionRange: Range(project.positions["2️⃣"]), @@ -310,6 +316,31 @@ final class CallHierarchyTests: XCTestCase { ) ] ) + + let testVarItem = try XCTUnwrap(calls?.first?.from) + + let callsToTestVar = try await project.testClient.send(CallHierarchyIncomingCallsRequest(item: testVarItem)) + XCTAssertEqual( + callsToTestVar, + [ + CallHierarchyIncomingCall( + from: CallHierarchyItem( + name: "testFunc()", + kind: .function, + tags: nil, + detail: nil, + uri: project.fileURI, + range: Range(project.positions["4️⃣"]), + selectionRange: Range(project.positions["4️⃣"]), + data: .dictionary([ + "usr": .string("s:4test0A4FuncyyF"), + "uri": .string(project.fileURI.stringValue), + ]) + ), + fromRanges: [Range(project.positions["5️⃣"])] + ) + ] + ) } func testIncomingCallHierarchyShowsAccessToVariables() async throws { @@ -339,7 +370,7 @@ final class CallHierarchyTests: XCTestCase { name: "testFunc()", kind: .function, tags: nil, - detail: "test", // test is the module name because the file is called test.swift + detail: nil, uri: project.fileURI, range: Range(project.positions["2️⃣"]), selectionRange: Range(project.positions["2️⃣"]), @@ -348,24 +379,8 @@ final class CallHierarchyTests: XCTestCase { "uri": .string(project.fileURI.stringValue), ]) ), - fromRanges: [Range(project.positions["3️⃣"])] - ), - CallHierarchyIncomingCall( - from: CallHierarchyItem( - name: "testFunc()", - kind: .function, - tags: nil, - detail: "test", // test is the module name because the file is called test.swift - uri: project.fileURI, - range: Range(project.positions["2️⃣"]), - selectionRange: Range(project.positions["2️⃣"]), - data: .dictionary([ - "usr": .string("s:4test0A4FuncyyF"), - "uri": .string(project.fileURI.stringValue), - ]) - ), - fromRanges: [Range(project.positions["4️⃣"])] - ), + fromRanges: [Range(project.positions["3️⃣"]), Range(project.positions["4️⃣"])] + ) ] ) } @@ -396,7 +411,7 @@ final class CallHierarchyTests: XCTestCase { name: "getter:foo", kind: .function, tags: nil, - detail: "test", // test is the module name because the file is called test.swift + detail: nil, uri: project.fileURI, range: Range(project.positions["1️⃣"]), selectionRange: Range(project.positions["1️⃣"]), @@ -436,7 +451,7 @@ final class CallHierarchyTests: XCTestCase { name: "testFunc()", kind: .function, tags: nil, - detail: "test", // test is the module name because the file is called test.swift + detail: nil, uri: project.fileURI, range: Range(project.positions["1️⃣"]), selectionRange: Range(project.positions["1️⃣"]), @@ -485,7 +500,7 @@ final class CallHierarchyTests: XCTestCase { name: "test(proto:)", kind: .function, tags: nil, - detail: "test", // test is the module name because the file is called test.swift + detail: nil, uri: project.fileURI, range: Range(project.positions["2️⃣"]), selectionRange: Range(project.positions["2️⃣"]), @@ -534,7 +549,7 @@ final class CallHierarchyTests: XCTestCase { name: "test(base:)", kind: .function, tags: nil, - detail: "test", // test is the module name because the file is called test.swift + detail: nil, uri: project.fileURI, range: Range(project.positions["2️⃣"]), selectionRange: Range(project.positions["2️⃣"]), @@ -548,4 +563,47 @@ final class CallHierarchyTests: XCTestCase { ] ) } + + func testCallHierarchyContainsContainerNameAsDetail() async throws { + let project = try await IndexedSingleSwiftFileTestProject( + """ + class MyClass { + func 1️⃣foo() { + 2️⃣bar() + } + } + func 3️⃣bar() { + } + """ + ) + let prepare = try await project.testClient.send( + CallHierarchyPrepareRequest( + textDocument: TextDocumentIdentifier(project.fileURI), + position: project.positions["3️⃣"] + ) + ) + let initialItem = try XCTUnwrap(prepare?.only) + let calls = try await project.testClient.send(CallHierarchyIncomingCallsRequest(item: initialItem)) + XCTAssertEqual( + calls, + [ + CallHierarchyIncomingCall( + from: CallHierarchyItem( + name: "foo()", + kind: .method, + tags: nil, + detail: "MyClass", + uri: project.fileURI, + range: Range(project.positions["1️⃣"]), + selectionRange: Range(project.positions["1️⃣"]), + data: .dictionary([ + "usr": .string("s:4test7MyClassC3fooyyF"), + "uri": .string(project.fileURI.stringValue), + ]) + ), + fromRanges: [Range(project.positions["2️⃣"])] + ) + ] + ) + } } diff --git a/Tests/SourceKitLSPTests/TestDiscoveryTests.swift b/Tests/SourceKitLSPTests/TestDiscoveryTests.swift index 7e48555ca..f2bd33739 100644 --- a/Tests/SourceKitLSPTests/TestDiscoveryTests.swift +++ b/Tests/SourceKitLSPTests/TestDiscoveryTests.swift @@ -47,27 +47,27 @@ final class TestDiscoveryTests: XCTestCase { XCTAssertEqual( tests, [ - WorkspaceSymbolItem.symbolInformation( - SymbolInformation( - name: "MyTests", - kind: .class, - location: Location( - uri: try project.uri(for: "MyTests.swift"), - range: Range(try project.position(of: "1️⃣", in: "MyTests.swift")) + TestItem( + id: "MyTests", + label: "MyTests", + location: Location( + uri: try project.uri(for: "MyTests.swift"), + range: Range(try project.position(of: "1️⃣", in: "MyTests.swift")) + ), + children: [ + TestItem( + id: "MyTests/testMyLibrary()", + label: "testMyLibrary()", + location: Location( + uri: try project.uri(for: "MyTests.swift"), + range: Range(try project.position(of: "2️⃣", in: "MyTests.swift")) + ), + children: [], + tags: [] ) - ) - ), - WorkspaceSymbolItem.symbolInformation( - SymbolInformation( - name: "testMyLibrary()", - kind: .method, - location: Location( - uri: try project.uri(for: "MyTests.swift"), - range: Range(try project.position(of: "2️⃣", in: "MyTests.swift")) - ), - containerName: "MyTests" - ) - ), + ], + tags: [] + ) ] ) } @@ -80,11 +80,11 @@ final class TestDiscoveryTests: XCTestCase { "Tests/MyLibraryTests/MyTests.swift": """ import XCTest - class 1️⃣MyTests: XCTestCase { - func 2️⃣testMyLibrary() {} + 1️⃣class MyTests: XCTestCase { + 2️⃣func testMyLibrary() {}3️⃣ func unrelatedFunc() {} var testVariable: Int = 0 - } + }4️⃣ """, "Tests/MyLibraryTests/MoreTests.swift": """ import XCTest @@ -112,27 +112,21 @@ final class TestDiscoveryTests: XCTestCase { XCTAssertEqual( tests, [ - WorkspaceSymbolItem.symbolInformation( - SymbolInformation( - name: "MyTests", - kind: .class, - location: Location( - uri: uri, - range: Range(positions["1️⃣"]) + TestItem( + id: "MyTests", + label: "MyTests", + location: Location(uri: uri, range: positions["1️⃣"]..