Skip to content

Commit aa6d6c1

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 f1db1e9 commit aa6d6c1

File tree

4 files changed

+103
-8
lines changed

4 files changed

+103
-8
lines changed

Sources/SKTestSupport/Utils.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
package import Foundation
1414
package import LanguageServerProtocol
1515
import SwiftExtensions
16+
import XCTest
1617

1718
import struct TSCBasic.AbsolutePath
1819

@@ -124,3 +125,15 @@ var globalModuleCache: URL? {
124125
.appendingPathComponent("shared-module-cache")
125126
}
126127
}
128+
129+
/// Similar to XCTAssertThrowsError, but accepts an async function.
130+
package func assertAsyncThrowsError<T>(
131+
_ fn: @autoclosure () async throws -> T,
132+
file: StaticString = #filePath,
133+
line: UInt = #line
134+
) async {
135+
do {
136+
_ = try await fn()
137+
XCTFail("Expected thrown error", file: file, line: line)
138+
} catch {}
139+
}

Sources/SKUtilities/LineTable.swift

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -116,20 +116,26 @@ extension LineTable {
116116
}
117117

118118
/// Replace the line table's `content` in the given range and update the line data.
119+
/// If the given range is out-of-bounds, logs an error and returns without mutating.
119120
///
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).
121+
/// - parameter utf8Offset: Starting UTF-8 offset (zero-based).
122+
/// - parameter length: UTF-8 length.
124123
/// - parameter replacement: The new text for the given range.
125124
@inlinable
126-
mutating package func replace(
125+
mutating package func tryReplace(
127126
utf8Offset fromOff: Int,
128127
length: Int,
129128
with replacement: String
130129
) {
131-
let start = content.utf8.index(content.startIndex, offsetBy: fromOff)
132-
let end = content.utf8.index(content.startIndex, offsetBy: fromOff + length)
130+
let utf8 = self.content.utf8
131+
guard
132+
fromOff >= 0, length >= 0,
133+
let start = utf8.index(utf8.startIndex, offsetBy: fromOff, limitedBy: utf8.endIndex),
134+
let end = utf8.index(start, offsetBy: length, limitedBy: utf8.endIndex)
135+
else {
136+
logger.error("Range end is out of bounds \(fromOff) + \(length) > \(utf8.count)")
137+
return
138+
}
133139

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

Sources/SwiftSourceKitPlugin/CodeCompletion/Connection.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,9 @@ final class Connection {
140140
return
141141
}
142142

143-
document.lineTable.replace(utf8Offset: offset, length: length, with: newText)
143+
// Try replace the range, ignoring an invalid input. This matches SourceKit's
144+
// behavior.
145+
document.lineTable.tryReplace(utf8Offset: offset, length: length, with: newText)
144146

145147
sourcekitd.ideApi.set_file_contents(impl, path, document.lineTable.content)
146148
}

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 assertAsyncThrowsError(
445+
try await sourcekitd.editDocument(path, fromOffset: 0, length: 99999, newContents: "")
446+
)
447+
await assertAsyncThrowsError(
448+
try await sourcekitd.editDocument(path, fromOffset: 99999, length: 1, newContents: "")
449+
)
450+
await assertAsyncThrowsError(
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)