Skip to content

Commit 331599e

Browse files
authored
Merge pull request #1798 from ahoppen/linetable-crash
Fix a crash when trying to apply in edit that has out of line positions
2 parents d913222 + da10fbb commit 331599e

File tree

7 files changed

+127
-67
lines changed

7 files changed

+127
-67
lines changed

Sources/SKSupport/LineTable.swift

Lines changed: 37 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -36,25 +36,16 @@ package struct LineTable: Hashable, Sendable {
3636
}
3737
}
3838

39-
/// The number of lines.
40-
@inlinable
41-
package var count: Int { return impl.count }
42-
43-
/// Returns the given (zero-based) line as a Substring, including the newline.
44-
///
45-
/// - parameter line: Line number (zero-based).
46-
@inlinable
47-
package subscript(line: Int) -> Substring {
48-
return content[impl[line]..<(line == count - 1 ? content.endIndex : impl[line + 1])]
49-
}
39+
/// The number of lines in the line table.
40+
package var lineCount: Int { impl.count }
5041

5142
/// Translate String.Index to logical line/utf16 pair.
5243
package func lineAndUTF16ColumnOf(_ index: String.Index, fromLine: Int = 0) -> (line: Int, utf16Column: Int) {
53-
precondition(0 <= fromLine && fromLine < count)
44+
precondition(0 <= fromLine && fromLine < impl.count)
5445

5546
// Binary search.
5647
var lower = fromLine
57-
var upper = count
48+
var upper = impl.count
5849
while true {
5950
let mid = lower + (upper - lower) / 2
6051
let lineStartIndex = impl[mid]
@@ -72,16 +63,6 @@ package struct LineTable: Hashable, Sendable {
7263
}
7364
}
7465

75-
extension LineTable: RandomAccessCollection {
76-
package var startIndex: Int {
77-
return impl.startIndex
78-
}
79-
80-
package var endIndex: Int {
81-
return impl.endIndex
82-
}
83-
}
84-
8566
extension LineTable {
8667

8768
// MARK: - Editing
@@ -101,8 +82,8 @@ extension LineTable {
10182
utf16Offset toOff: Int,
10283
with replacement: String
10384
) {
104-
let start = content.utf16.index(impl[fromLine], offsetBy: fromOff)
105-
let end = content.utf16.index(impl[toLine], offsetBy: toOff)
85+
let start = self.stringIndexOf(line: fromLine, utf16Column: fromOff)
86+
let end = self.stringIndexOf(line: toLine, utf16Column: toOff)
10687

10788
var newText = self.content
10889
newText.replaceSubrange(start..<end, with: replacement)
@@ -122,8 +103,14 @@ extension LineTable {
122103
utf16Length: Int,
123104
with replacement: String
124105
) {
125-
let start = content.utf16.index(impl[fromLine], offsetBy: fromOff)
126-
let end = content.utf16.index(start, offsetBy: utf16Length)
106+
let start = self.stringIndexOf(line: fromLine, utf16Column: fromOff)
107+
let end: String.UTF16View.Index
108+
if let endValue = content.utf16.index(start, offsetBy: utf16Length, limitedBy: content.endIndex) {
109+
end = endValue
110+
} else {
111+
logger.fault("Range end is past end of file \(fromLine):\(fromOff) + \(utf16Length)")
112+
end = content.endIndex
113+
}
127114
let (toLine, toOff) = lineAndUTF16ColumnOf(end, fromLine: fromLine)
128115
self.replace(fromLine: fromLine, utf16Offset: fromOff, toLine: toLine, utf16Offset: toOff, with: replacement)
129116
}
@@ -158,15 +145,36 @@ extension LineTable {
158145
)
159146
return .beforeFirstLine
160147
}
161-
guard line < count else {
148+
guard line < impl.count else {
162149
logger.fault(
163150
"""
164151
Line \(line) is out-of range (\(callerFile, privacy: .public):\(callerLine, privacy: .public))
165152
"""
166153
)
167154
return .afterLastLine
168155
}
169-
return .line(self[line])
156+
let start = impl[line]
157+
let end: String.Index
158+
if line + 1 < impl.count {
159+
end = impl[line + 1]
160+
} else {
161+
end = content.endIndex
162+
}
163+
164+
return .line(content[start..<end])
165+
}
166+
167+
/// Extracts the contents of the line at the given index.
168+
///
169+
/// If the line is out-of-bounds, returns `nil` and logs a fault.
170+
@inlinable
171+
package func line(at line: Int, callerFile: StaticString = #fileID, callerLine: UInt = #line) -> Substring? {
172+
switch lineSlice(at: line, callerFile: callerFile, callerLine: callerLine) {
173+
case .beforeFirstLine, .afterLastLine:
174+
return nil
175+
case .line(let line):
176+
return line
177+
}
170178
}
171179

172180
/// Converts the given UTF-16-based `line:column`` position to a `String.Index`.

Sources/SourceKitLSP/Swift/CodeCompletionSession.swift

Lines changed: 43 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -417,46 +417,64 @@ class CodeCompletionSession {
417417
newText: String,
418418
snapshot: DocumentSnapshot
419419
) -> TextEdit {
420-
let textEditRangeStart: Position
420+
let textEditRangeStart = computeCompletionTextEditStart(
421+
completionPos: completionPos,
422+
requestPosition: requestPosition,
423+
utf8CodeUnitsToErase: utf8CodeUnitsToErase,
424+
snapshot: snapshot
425+
)
426+
return TextEdit(range: textEditRangeStart..<requestPosition, newText: newText)
427+
}
421428

429+
private func computeCompletionTextEditStart(
430+
completionPos: Position,
431+
requestPosition: Position,
432+
utf8CodeUnitsToErase: Int,
433+
snapshot: DocumentSnapshot
434+
) -> Position {
422435
// Compute the TextEdit
423436
if utf8CodeUnitsToErase == 0 {
424437
// Nothing to delete. Fast path and avoid UTF-8/UTF-16 conversions
425-
textEditRangeStart = completionPos
438+
return completionPos
426439
} else if utf8CodeUnitsToErase == 1 {
427440
// 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
428441
if completionPos.utf16index >= 1 {
429442
// We can delete the character.
430-
textEditRangeStart = Position(line: completionPos.line, utf16index: completionPos.utf16index - 1)
431-
} else {
432-
// Deleting the character would cross line boundaries. This is not supported by LSP.
433-
// Fall back to ignoring utf8CodeUnitsToErase.
434-
// If we discover that multi-lines replacements are often needed, we can add an LSP extension to support multi-line edits.
435-
textEditRangeStart = completionPos
436-
}
437-
} else {
438-
// We need to delete more than one text character. Do the UTF-8/UTF-16 dance.
439-
assert(completionPos.line == requestPosition.line)
440-
// Construct a string index for the edit range start by subtracting the UTF-8 code units to erase from the completion position.
441-
let line = snapshot.lineTable[completionPos.line]
442-
let deletionStartStringIndex = line.utf8.index(snapshot.index(of: completionPos), offsetBy: -utf8CodeUnitsToErase)
443-
444-
// Compute the UTF-16 offset of the deletion start range. If the start lies in a previous line, this will be negative
445-
let deletionStartUtf16Offset = line.utf16.distance(from: line.startIndex, to: deletionStartStringIndex)
446-
447-
// Check if we are only deleting on one line. LSP does not support deleting over multiple lines.
448-
if deletionStartUtf16Offset >= 0 {
449-
// We are only deleting characters on the same line. Construct the corresponding text edit.
450-
textEditRangeStart = Position(line: completionPos.line, utf16index: deletionStartUtf16Offset)
443+
return Position(line: completionPos.line, utf16index: completionPos.utf16index - 1)
451444
} else {
452445
// Deleting the character would cross line boundaries. This is not supported by LSP.
453446
// Fall back to ignoring utf8CodeUnitsToErase.
454447
// If we discover that multi-lines replacements are often needed, we can add an LSP extension to support multi-line edits.
455-
textEditRangeStart = completionPos
448+
return completionPos
456449
}
457450
}
458451

459-
return TextEdit(range: textEditRangeStart..<requestPosition, newText: newText)
452+
// We need to delete more than one text character. Do the UTF-8/UTF-16 dance.
453+
assert(completionPos.line == requestPosition.line)
454+
// Construct a string index for the edit range start by subtracting the UTF-8 code units to erase from the completion position.
455+
guard let line = snapshot.lineTable.line(at: completionPos.line) else {
456+
logger.fault("Code completion position is in out-of-range line \(completionPos.line)")
457+
return completionPos
458+
}
459+
guard
460+
let deletionStartStringIndex = line.utf8.index(
461+
snapshot.index(of: completionPos),
462+
offsetBy: -utf8CodeUnitsToErase,
463+
limitedBy: line.utf8.startIndex
464+
)
465+
else {
466+
// Deleting the character would cross line boundaries. This is not supported by LSP.
467+
// Fall back to ignoring utf8CodeUnitsToErase.
468+
// If we discover that multi-lines replacements are often needed, we can add an LSP extension to support multi-line edits.
469+
logger.fault("UTF-8 code units to erase \(utf8CodeUnitsToErase) is before start of line")
470+
return completionPos
471+
}
472+
473+
// Compute the UTF-16 offset of the deletion start range.
474+
let deletionStartUtf16Offset = line.utf16.distance(from: line.startIndex, to: deletionStartStringIndex)
475+
precondition(deletionStartUtf16Offset >= 0)
476+
477+
return Position(line: completionPos.line, utf16index: deletionStartUtf16Offset)
460478
}
461479
}
462480

Sources/SourceKitLSP/Swift/FoldingRange.swift

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -287,11 +287,12 @@ extension SwiftLanguageService {
287287

288288
fileprivate extension LineTable {
289289
func isAtEndOfLine(_ position: Position) -> Bool {
290-
guard position.line >= 0, position.line < self.count else {
290+
guard let line = self.line(at: position.line) else {
291291
return false
292292
}
293-
let line = self[position.line]
294-
let suffixAfterPositionColumn = line[line.utf16.index(line.startIndex, offsetBy: position.utf16index)...]
295-
return suffixAfterPositionColumn.allSatisfy(\.isNewline)
293+
guard let index = line.utf16.index(line.startIndex, offsetBy: position.utf16index, limitedBy: line.endIndex) else {
294+
return false
295+
}
296+
return line[index...].allSatisfy(\.isNewline)
296297
}
297298
}

Tests/SKSupportTests/LineTablePerfTests.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,9 @@ final class LineTablePerfTests: PerfTestCase {
8787
self.startMeasuring()
8888

8989
for _ in 1...iterations {
90-
let line = (0..<(t.count - 1)).randomElement(using: &lcg) ?? 0
91-
let col = (0..<t[line].utf16.count).randomElement(using: &lcg) ?? 0
92-
let len = t[line].isEmpty ? 0 : Bool.random() ? 1 : 0
90+
let line = (0..<(t.lineCount - 1)).randomElement(using: &lcg) ?? 0
91+
let col = (0..<t.line(at: line)!.utf16.count).randomElement(using: &lcg) ?? 0
92+
let len = t.line(at: line)!.isEmpty ? 0 : Bool.random() ? 1 : 0
9393
var newText = String(characters.randomElement(using: &lcg)!)
9494
if len == 1 && Bool.random(using: &lcg) {
9595
newText = "" // deletion

Tests/SKSupportTests/SupportTests.swift

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,22 @@ import SKSupport
1414
import TSCBasic
1515
import XCTest
1616

17-
final class SupportTests: XCTestCase {
17+
fileprivate extension LineTable {
18+
var lines: [Substring] {
19+
(0..<lineCount).map { line(at: $0)! }
20+
}
21+
}
1822

23+
final class SupportTests: XCTestCase {
1924
func checkLines(_ string: String, _ expected: [String], file: StaticString = #filePath, line: UInt = #line) {
2025
let table = LineTable(string)
21-
XCTAssertEqual(table.map { String($0) }, expected, file: file, line: line)
26+
XCTAssertEqual(table.lines.map(String.init), expected, file: file, line: line)
2227
}
2328

2429
func checkOffsets(_ string: String, _ expected: [Int], file: StaticString = #filePath, line: UInt = #line) {
2530
let table = LineTable(string)
2631
XCTAssertEqual(
27-
table.map { string.utf8.distance(from: string.startIndex, to: $0.startIndex) },
32+
table.lines.map { string.utf8.distance(from: string.startIndex, to: $0.startIndex) },
2833
expected,
2934
file: file,
3035
line: line
@@ -69,8 +74,8 @@ final class SupportTests: XCTestCase {
6974
checkOffsets("\n\u{10000}b", [0, 1])
7075
checkOffsets("\n\u{10000}b\nc", [0, 1, 7])
7176

72-
XCTAssertEqual(LineTable("")[0], "")
73-
XCTAssertEqual(LineTable("\n")[1], "")
77+
XCTAssertEqual(LineTable("").line(at: 0), "")
78+
XCTAssertEqual(LineTable("\n").line(at: 1), "")
7479
}
7580

7681
func checkLineAndColumns(

Tests/SourceKitLSPTests/LifecycleTests.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,4 +135,31 @@ final class LifecycleTests: XCTestCase {
135135
)
136136
XCTAssertGreaterThan(symbolInfo.count, 0)
137137
}
138+
139+
func testEditWithOutOfRangeLine() async throws {
140+
let testClient = try await TestSourceKitLSPClient()
141+
let uri = DocumentURI(for: .swift)
142+
testClient.openDocument("", uri: uri)
143+
144+
// Check that we don't crash.
145+
testClient.send(
146+
DidChangeTextDocumentNotification(
147+
textDocument: VersionedTextDocumentIdentifier(uri, version: 2),
148+
contentChanges: [TextDocumentContentChangeEvent(range: Range(Position(line: 2, utf16index: 0)), text: "new")]
149+
)
150+
)
151+
}
152+
153+
func testEditWithOutOfRangeColumn() async throws {
154+
let testClient = try await TestSourceKitLSPClient()
155+
let uri = DocumentURI(for: .swift)
156+
testClient.openDocument("", uri: uri)
157+
158+
testClient.send(
159+
DidChangeTextDocumentNotification(
160+
textDocument: VersionedTextDocumentIdentifier(uri, version: 2),
161+
contentChanges: [TextDocumentContentChangeEvent(range: Range(Position(line: 0, utf16index: 4)), text: "new")]
162+
)
163+
)
164+
}
138165
}

Tests/SourceKitLSPTests/SwiftInterfaceTests.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ private func assertSystemSwiftInterface(
200200
// load contents of swiftinterface
201201
let contents = try XCTUnwrap(location.uri.fileURL.flatMap({ try String(contentsOf: $0, encoding: .utf8) }))
202202
let lineTable = LineTable(contents)
203-
let destinationLine = lineTable[location.range.lowerBound.line].trimmingCharacters(in: .whitespaces)
203+
let destinationLine = try XCTUnwrap(lineTable.line(at: location.range.lowerBound.line))
204+
.trimmingCharacters(in: .whitespaces)
204205
XCTAssert(destinationLine.hasPrefix(linePrefix), "Full line was: '\(destinationLine)'", line: line)
205206
}

0 commit comments

Comments
 (0)