Skip to content

Commit 9e552dd

Browse files
committed
Avoid crashing on invalid range in editDocument
Rename `LineTable.replace(utf8Offset:length:with)` to `tryReplace` and bail if the provided range is out of bounds of the buffer. This ensures we match the behavior of SourceKit when handling an `editor.replacetext` request. rdar://161268691
1 parent c3c1b8e commit 9e552dd

File tree

3 files changed

+113
-10
lines changed

3 files changed

+113
-10
lines changed

Sources/SKUtilities/LineTable.swift

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -115,21 +115,47 @@ extension LineTable {
115115
self.replace(fromLine: fromLine, utf16Offset: fromOff, toLine: toLine, utf16Offset: toOff, with: replacement)
116116
}
117117

118+
private struct OutOfBoundsError: Error, CustomLogStringConvertible {
119+
// Note we use tuples here rather than Range since the latter would assert
120+
// that upperBound >= lowerBound.
121+
var utf8Range: (lower: Int, upper: Int)
122+
var utf8Bounds: (lower: Int, upper: Int)
123+
124+
var description: String {
125+
"""
126+
\(utf8Range.lower)..<\(utf8Range.upper) is out of bounds \
127+
\(utf8Bounds.lower)..<\(utf8Bounds.upper)
128+
"""
129+
}
130+
131+
var redactedDescription: String {
132+
description
133+
}
134+
}
135+
118136
/// Replace the line table's `content` in the given range and update the line data.
137+
/// If the given range is out-of-bounds, throws an error.
119138
///
120-
/// - parameter fromLine: Starting line number (zero-based).
121-
/// - parameter fromOff: Starting UTF-8 column offset (zero-based).
122-
/// - parameter toLine: Ending line number (zero-based).
123-
/// - parameter toOff: Ending UTF-8 column offset (zero-based).
139+
/// - parameter utf8Offset: Starting UTF-8 offset (zero-based).
140+
/// - parameter length: UTF-8 length.
124141
/// - parameter replacement: The new text for the given range.
125142
@inlinable
126-
mutating package func replace(
143+
mutating package func tryReplace(
127144
utf8Offset fromOff: Int,
128145
length: Int,
129146
with replacement: String
130-
) {
131-
let start = content.utf8.index(content.startIndex, offsetBy: fromOff)
132-
let end = content.utf8.index(content.startIndex, offsetBy: fromOff + length)
147+
) throws {
148+
let utf8 = self.content.utf8
149+
guard
150+
fromOff >= 0, length >= 0,
151+
let start = utf8.index(utf8.startIndex, offsetBy: fromOff, limitedBy: utf8.endIndex),
152+
let end = utf8.index(start, offsetBy: length, limitedBy: utf8.endIndex)
153+
else {
154+
throw OutOfBoundsError(
155+
utf8Range: (lower: fromOff, upper: fromOff + length),
156+
utf8Bounds: (lower: 0, upper: utf8.count)
157+
)
158+
}
133159

134160
var newText = self.content
135161
newText.replaceSubrange(start..<end, with: replacement)

Sources/SwiftSourceKitPlugin/CodeCompletion/Connection.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,11 @@ final class Connection {
140140
return
141141
}
142142

143-
document.lineTable.replace(utf8Offset: offset, length: length, with: newText)
144-
143+
// Try replace the range, ignoring an invalid input. This matches SourceKit's
144+
// behavior.
145+
orLog("Replacing text") {
146+
try document.lineTable.tryReplace(utf8Offset: offset, length: length, with: newText)
147+
}
145148
sourcekitd.ideApi.set_file_contents(impl, path, document.lineTable.content)
146149
}
147150

Tests/SwiftSourceKitPluginTests/SwiftSourceKitPluginTests.swift

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,80 @@ final class SwiftSourceKitPluginTests: XCTestCase {
405405
XCTAssertEqual(result3.items.count, 1)
406406
}
407407

408+
func testEditBounds() async throws {
409+
try await SkipUnless.sourcekitdSupportsPlugin()
410+
let sourcekitd = try await getSourceKitD()
411+
let path = scratchFilePath()
412+
_ = try await sourcekitd.openDocument(
413+
path,
414+
contents: "",
415+
compilerArguments: [path]
416+
)
417+
418+
let typeWithMethod = """
419+
struct S {
420+
static func foo() -> Int {}
421+
}
422+
423+
"""
424+
var fullText = typeWithMethod
425+
426+
try await sourcekitd.editDocument(path, fromOffset: 0, length: 0, newContents: typeWithMethod)
427+
428+
let completion = """
429+
S.
430+
"""
431+
fullText += completion
432+
433+
try await sourcekitd.editDocument(path, fromOffset: typeWithMethod.utf8.count, length: 0, newContents: completion)
434+
435+
func testCompletion(file: StaticString = #filePath, line: UInt = #line) async throws {
436+
let result = try await sourcekitd.completeOpen(
437+
path: path,
438+
position: Position(line: 3, utf16index: 2),
439+
filter: "foo",
440+
flags: []
441+
)
442+
XCTAssertGreaterThan(result.unfilteredResultCount, 1, file: file, line: line)
443+
XCTAssertEqual(result.items.count, 1, file: file, line: line)
444+
}
445+
try await testCompletion()
446+
447+
// Bogus edits are ignored (negative offsets crash SourceKit itself so we don't test them here).
448+
await assertThrowsError(
449+
try await sourcekitd.editDocument(path, fromOffset: 0, length: 99999, newContents: "")
450+
)
451+
await assertThrowsError(
452+
try await sourcekitd.editDocument(path, fromOffset: 99999, length: 1, newContents: "")
453+
)
454+
await assertThrowsError(
455+
try await sourcekitd.editDocument(path, fromOffset: 99999, length: 0, newContents: "unrelated")
456+
)
457+
// SourceKit doesn't throw an error for a no-op edit.
458+
try await sourcekitd.editDocument(path, fromOffset: 99999, length: 0, newContents: "")
459+
460+
try await sourcekitd.editDocument(path, fromOffset: 0, length: 0, newContents: "")
461+
try await sourcekitd.editDocument(path, fromOffset: fullText.utf8.count, length: 0, newContents: "")
462+
463+
try await testCompletion()
464+
465+
let badCompletion = """
466+
X.
467+
"""
468+
fullText = fullText.dropLast(2) + badCompletion
469+
470+
try await sourcekitd.editDocument(path, fromOffset: fullText.utf8.count - 2, length: 2, newContents: badCompletion)
471+
472+
let result = try await sourcekitd.completeOpen(
473+
path: path,
474+
position: Position(line: 3, utf16index: 2),
475+
filter: "foo",
476+
flags: []
477+
)
478+
XCTAssertEqual(result.unfilteredResultCount, 0)
479+
XCTAssertEqual(result.items.count, 0)
480+
}
481+
408482
func testDocumentation() async throws {
409483
try await SkipUnless.sourcekitdSupportsPlugin()
410484
try await SkipUnless.sourcekitdSupportsFullDocumentationInCompletion()

0 commit comments

Comments
 (0)