Skip to content

Commit b8da832

Browse files
authored
Merge pull request #2179 from hamishknight/bound-check
Avoid crashing on invalid range in `editDocument`
2 parents 555816c + 9e552dd commit b8da832

File tree

3 files changed

+109
-47
lines changed

3 files changed

+109
-47
lines changed

Sources/SKUtilities/LineTable.swift

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

118-
/// Replace the line table's `content` in the given range and update the line data.
119-
///
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).
124-
/// - parameter replacement: The new text for the given range.
125-
@inlinable
126-
mutating package func replace(
127-
fromLine: Int,
128-
utf8Offset fromOff: Int,
129-
toLine: Int,
130-
utf8Offset toOff: Int,
131-
with replacement: String
132-
) {
133-
let start = self.stringIndexOf(line: fromLine, utf8Column: fromOff)
134-
let end = self.stringIndexOf(line: toLine, utf8Column: toOff)
135-
136-
var newText = self.content
137-
newText.replaceSubrange(start..<end, with: replacement)
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+
}
138130

139-
self = LineTable(newText)
131+
var redactedDescription: String {
132+
description
133+
}
140134
}
141135

142136
/// 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.
143138
///
144-
/// - parameter fromLine: Starting line number (zero-based).
145-
/// - parameter fromOff: Starting UTF-8 column offset (zero-based).
146-
/// - parameter toLine: Ending line number (zero-based).
147-
/// - parameter toOff: Ending UTF-8 column offset (zero-based).
139+
/// - parameter utf8Offset: Starting UTF-8 offset (zero-based).
140+
/// - parameter length: UTF-8 length.
148141
/// - parameter replacement: The new text for the given range.
149142
@inlinable
150-
mutating package func replace(
143+
mutating package func tryReplace(
151144
utf8Offset fromOff: Int,
152145
length: Int,
153146
with replacement: String
154-
) {
155-
let start = content.utf8.index(content.startIndex, offsetBy: fromOff)
156-
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+
}
157159

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

Sources/SwiftSourceKitPlugin/CodeCompletion/Connection.swift

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

143-
document.lineTable.replace(utf8Offset: offset, length: length, with: newText)
144-
145-
sourcekitd.ideApi.set_file_contents(impl, path, document.lineTable.content)
146-
}
147-
148-
func editDocument(path: String, edit: TextEdit) {
149-
guard let document = documents[path] else {
150-
logger.error("Document at '\(path)' is not open")
151-
return
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)
152147
}
153-
154-
document.lineTable.replace(
155-
fromLine: edit.range.lowerBound.line - 1,
156-
utf8Offset: edit.range.lowerBound.utf8Column - 1,
157-
toLine: edit.range.upperBound.line - 1,
158-
utf8Offset: edit.range.upperBound.utf8Column - 1,
159-
with: edit.newText
160-
)
161-
162148
sourcekitd.ideApi.set_file_contents(impl, path, document.lineTable.content)
163149
}
164150

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)