From da10fbbdcb32f8fae88082c424104786cedbba94 Mon Sep 17 00:00:00 2001 From: Alex Hoppen Date: Wed, 30 Oct 2024 20:01:22 -0700 Subject: [PATCH] Fix a crash when trying to apply in edit that has out of line positions `LineTable.replace` did not actually validate that the edit is in range. This could cause crashes of SourceKit-LSP if the editor is sending us bogus edits. Validate the position, like we do in all the other position conversions and log a fault if the edit is out-of-range. While fixing this, I found a couple more places where line table accesses were not properly guarded. I migrated them to safe alternatives as well. rdar://138962353 --- Sources/SKSupport/LineTable.swift | 66 ++++++++++-------- .../Swift/CodeCompletionSession.swift | 68 ++++++++++++------- Sources/SourceKitLSP/Swift/FoldingRange.swift | 9 +-- Tests/SKSupportTests/LineTablePerfTests.swift | 6 +- Tests/SKSupportTests/SupportTests.swift | 15 ++-- Tests/SourceKitLSPTests/LifecycleTests.swift | 27 ++++++++ .../SwiftInterfaceTests.swift | 3 +- 7 files changed, 127 insertions(+), 67 deletions(-) diff --git a/Sources/SKSupport/LineTable.swift b/Sources/SKSupport/LineTable.swift index a80294340..67296f993 100644 --- a/Sources/SKSupport/LineTable.swift +++ b/Sources/SKSupport/LineTable.swift @@ -36,25 +36,16 @@ package struct LineTable: Hashable, Sendable { } } - /// The number of lines. - @inlinable - package var count: Int { return impl.count } - - /// Returns the given (zero-based) line as a Substring, including the newline. - /// - /// - parameter line: Line number (zero-based). - @inlinable - package subscript(line: Int) -> Substring { - return content[impl[line]..<(line == count - 1 ? content.endIndex : impl[line + 1])] - } + /// The number of lines in the line table. + package var lineCount: Int { impl.count } /// Translate String.Index to logical line/utf16 pair. package func lineAndUTF16ColumnOf(_ index: String.Index, fromLine: Int = 0) -> (line: Int, utf16Column: Int) { - precondition(0 <= fromLine && fromLine < count) + precondition(0 <= fromLine && fromLine < impl.count) // Binary search. var lower = fromLine - var upper = count + var upper = impl.count while true { let mid = lower + (upper - lower) / 2 let lineStartIndex = impl[mid] @@ -72,16 +63,6 @@ package struct LineTable: Hashable, Sendable { } } -extension LineTable: RandomAccessCollection { - package var startIndex: Int { - return impl.startIndex - } - - package var endIndex: Int { - return impl.endIndex - } -} - extension LineTable { // MARK: - Editing @@ -101,8 +82,8 @@ extension LineTable { utf16Offset toOff: Int, with replacement: String ) { - let start = content.utf16.index(impl[fromLine], offsetBy: fromOff) - let end = content.utf16.index(impl[toLine], offsetBy: toOff) + let start = self.stringIndexOf(line: fromLine, utf16Column: fromOff) + let end = self.stringIndexOf(line: toLine, utf16Column: toOff) var newText = self.content newText.replaceSubrange(start.. Substring? { + switch lineSlice(at: line, callerFile: callerFile, callerLine: callerLine) { + case .beforeFirstLine, .afterLastLine: + return nil + case .line(let line): + return line + } } /// Converts the given UTF-16-based `line:column`` position to a `String.Index`. diff --git a/Sources/SourceKitLSP/Swift/CodeCompletionSession.swift b/Sources/SourceKitLSP/Swift/CodeCompletionSession.swift index d419cfcfa..e14d8974f 100644 --- a/Sources/SourceKitLSP/Swift/CodeCompletionSession.swift +++ b/Sources/SourceKitLSP/Swift/CodeCompletionSession.swift @@ -417,46 +417,64 @@ class CodeCompletionSession { newText: String, snapshot: DocumentSnapshot ) -> TextEdit { - let textEditRangeStart: Position + let textEditRangeStart = computeCompletionTextEditStart( + completionPos: completionPos, + requestPosition: requestPosition, + utf8CodeUnitsToErase: utf8CodeUnitsToErase, + snapshot: snapshot + ) + return TextEdit(range: textEditRangeStart.. Position { // Compute the TextEdit if utf8CodeUnitsToErase == 0 { // Nothing to delete. Fast path and avoid UTF-8/UTF-16 conversions - textEditRangeStart = completionPos + return completionPos } else if utf8CodeUnitsToErase == 1 { // Fast path: Erasing a single UTF-8 byte code unit means we are also need to erase exactly one UTF-16 code unit, meaning we don't need to process the file contents if completionPos.utf16index >= 1 { // We can delete the character. - textEditRangeStart = Position(line: completionPos.line, utf16index: completionPos.utf16index - 1) - } else { - // Deleting the character would cross line boundaries. This is not supported by LSP. - // Fall back to ignoring utf8CodeUnitsToErase. - // If we discover that multi-lines replacements are often needed, we can add an LSP extension to support multi-line edits. - textEditRangeStart = completionPos - } - } else { - // We need to delete more than one text character. Do the UTF-8/UTF-16 dance. - 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] - 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) - - // Check if we are only deleting on one line. LSP does not support deleting over multiple lines. - if deletionStartUtf16Offset >= 0 { - // We are only deleting characters on the same line. Construct the corresponding text edit. - textEditRangeStart = Position(line: completionPos.line, utf16index: deletionStartUtf16Offset) + return Position(line: completionPos.line, utf16index: completionPos.utf16index - 1) } else { // Deleting the character would cross line boundaries. This is not supported by LSP. // Fall back to ignoring utf8CodeUnitsToErase. // If we discover that multi-lines replacements are often needed, we can add an LSP extension to support multi-line edits. - textEditRangeStart = completionPos + return completionPos } } - return TextEdit(range: textEditRangeStart..= 0) + + return Position(line: completionPos.line, utf16index: deletionStartUtf16Offset) } } diff --git a/Sources/SourceKitLSP/Swift/FoldingRange.swift b/Sources/SourceKitLSP/Swift/FoldingRange.swift index e1107a0ee..5a1894a55 100644 --- a/Sources/SourceKitLSP/Swift/FoldingRange.swift +++ b/Sources/SourceKitLSP/Swift/FoldingRange.swift @@ -287,11 +287,12 @@ extension SwiftLanguageService { fileprivate extension LineTable { func isAtEndOfLine(_ position: Position) -> Bool { - guard position.line >= 0, position.line < self.count else { + guard let line = self.line(at: position.line) else { return false } - let line = self[position.line] - let suffixAfterPositionColumn = line[line.utf16.index(line.startIndex, offsetBy: position.utf16index)...] - return suffixAfterPositionColumn.allSatisfy(\.isNewline) + guard let index = line.utf16.index(line.startIndex, offsetBy: position.utf16index, limitedBy: line.endIndex) else { + return false + } + return line[index...].allSatisfy(\.isNewline) } } diff --git a/Tests/SKSupportTests/LineTablePerfTests.swift b/Tests/SKSupportTests/LineTablePerfTests.swift index 72a19a794..b5840249f 100644 --- a/Tests/SKSupportTests/LineTablePerfTests.swift +++ b/Tests/SKSupportTests/LineTablePerfTests.swift @@ -87,9 +87,9 @@ final class LineTablePerfTests: PerfTestCase { self.startMeasuring() for _ in 1...iterations { - let line = (0..<(t.count - 1)).randomElement(using: &lcg) ?? 0 - let col = (0..