Skip to content

Commit d93fecb

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://90385969
1 parent 49c81a5 commit d93fecb

File tree

3 files changed

+110
-9
lines changed

3 files changed

+110
-9
lines changed

Sources/SKUtilities/LineTable.swift

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

118+
private struct OutOfBoundsError : Error, CustomLogStringConvertible {
119+
var utf8Range: (lower: Int, upper: Int)
120+
var utf8Bounds: (lower: Int, upper: Int)
121+
122+
var description: String {
123+
"""
124+
\(utf8Range.lower)..<\(utf8Range.upper) is out of bounds \
125+
\(utf8Bounds.lower)..<\(utf8Bounds.upper)
126+
"""
127+
}
128+
129+
var redactedDescription: String {
130+
description
131+
}
132+
}
133+
118134
/// Replace the line table's `content` in the given range and update the line data.
135+
/// If the given range is out-of-bounds, throws an error.
119136
///
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).
137+
/// - parameter utf8Offset: Starting UTF-8 offset (zero-based).
138+
/// - parameter length: UTF-8 length.
124139
/// - parameter replacement: The new text for the given range.
125140
@inlinable
126141
mutating package func replace(
127142
utf8Offset fromOff: Int,
128143
length: Int,
129144
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)
145+
) throws {
146+
let utf8 = self.content.utf8
147+
guard
148+
fromOff >= 0, length >= 0,
149+
let start = utf8.index(utf8.startIndex, offsetBy: fromOff, limitedBy: utf8.endIndex),
150+
let end = utf8.index(start, offsetBy: length, limitedBy: utf8.endIndex)
151+
else {
152+
throw OutOfBoundsError(
153+
utf8Range: (lower: fromOff, upper: fromOff + length),
154+
utf8Bounds: (lower: 0, upper: utf8.count)
155+
)
156+
}
133157

134158
var newText = self.content
135159
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.replace(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
@@ -401,6 +401,80 @@ final class SwiftSourceKitPluginTests: XCTestCase {
401401
XCTAssertEqual(result3.items.count, 1)
402402
}
403403

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

0 commit comments

Comments
 (0)