Skip to content

Commit 6efe162

Browse files
authored
Merge pull request #1118 from ahoppen/ahoppen/concurrent-edits
Use logic in swift-syntax to translate sequential edits to concurrent edits
2 parents a6fdd1d + 9e6e52a commit 6efe162

File tree

2 files changed

+80
-61
lines changed

2 files changed

+80
-61
lines changed

Sources/SourceKitLSP/Swift/DocumentFormatting.swift

Lines changed: 27 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
//===----------------------------------------------------------------------===//
1212

1313
import Foundation
14+
import LSPLogging
1415
import LanguageServerProtocol
16+
import SwiftParser
17+
import SwiftSyntax
1518

1619
import struct TSCBasic.AbsolutePath
1720
import class TSCBasic.Process
@@ -97,74 +100,38 @@ extension CollectionDifference.Change {
97100

98101
/// Compute the text edits that need to be made to transform `original` into `edited`.
99102
private func edits(from original: DocumentSnapshot, to edited: String) -> [TextEdit] {
100-
let difference = edited.difference(from: original.text)
101-
102-
// `Collection.difference` returns sequential edits that are expected to be applied on-by-one. Offsets reference
103-
// the string that results if all previous edits are applied.
104-
// LSP expects concurrent edits that are applied simultaneously. Translate between them.
105-
106-
struct StringBasedEdit {
107-
/// Offset into the collection originalString.
108-
/// Ie. to get a string index out of this, run `original(original.startIndex, offsetBy: range.lowerBound)`.
109-
var range: Range<Int>
110-
/// The string the range is being replaced with.
111-
var replacement: String
112-
}
103+
let difference = edited.utf8.difference(from: original.text.utf8)
113104

114-
var edits: [StringBasedEdit] = []
115-
for change in difference {
116-
// Adjust the index offset based on changes that `Collection.difference` expects to already have been applied.
117-
var adjustment: Int = 0
118-
for edit in edits {
119-
if edit.range.upperBound < change.offset + adjustment {
120-
adjustment = adjustment + edit.range.count - edit.replacement.count
121-
}
122-
}
123-
let adjustedOffset = change.offset + adjustment
124-
let edit =
125-
switch change {
126-
case .insert(offset: _, element: let element, associatedWith: _):
127-
StringBasedEdit(range: adjustedOffset..<adjustedOffset, replacement: String(element))
128-
case .remove(offset: _, element: _, associatedWith: _):
129-
StringBasedEdit(range: adjustedOffset..<(adjustedOffset + 1), replacement: "")
130-
}
131-
132-
// If we have an existing edit that is adjacent to this one, merge them.
133-
// Otherwise, just append them.
134-
if let mergableEditIndex = edits.firstIndex(where: {
135-
$0.range.upperBound == edit.range.lowerBound || edit.range.upperBound == $0.range.lowerBound
136-
}) {
137-
let mergableEdit = edits[mergableEditIndex]
138-
if mergableEdit.range.upperBound == edit.range.lowerBound {
139-
edits[mergableEditIndex] = StringBasedEdit(
140-
range: mergableEdit.range.lowerBound..<edit.range.upperBound,
141-
replacement: mergableEdit.replacement + edit.replacement
142-
)
143-
} else {
144-
precondition(edit.range.upperBound == mergableEdit.range.lowerBound)
145-
edits[mergableEditIndex] = StringBasedEdit(
146-
range: edit.range.lowerBound..<mergableEdit.range.upperBound,
147-
replacement: edit.replacement + mergableEdit.replacement
148-
)
149-
}
150-
} else {
151-
edits.append(edit)
105+
let sequentialEdits = difference.map { change in
106+
switch change {
107+
case .insert(offset: let offset, element: let element, associatedWith: _):
108+
IncrementalEdit(offset: offset, length: 0, replacement: [element])
109+
case .remove(offset: let offset, element: _, associatedWith: _):
110+
IncrementalEdit(offset: offset, length: 1, replacement: [])
152111
}
153112
}
154113

155-
// Map the string-based edits to line-column based edits to be consumed by LSP
114+
let concurrentEdits = ConcurrentEdits(fromSequential: sequentialEdits)
156115

157-
return edits.map { edit in
158-
let (startLine, startColumn) = original.lineTable.lineAndUTF16ColumnOf(
159-
original.text.index(original.text.startIndex, offsetBy: edit.range.lowerBound)
160-
)
161-
let (endLine, endColumn) = original.lineTable.lineAndUTF16ColumnOf(
162-
original.text.index(original.text.startIndex, offsetBy: edit.range.upperBound)
163-
)
116+
// Map the offset-based edits to line-column based edits to be consumed by LSP
117+
118+
return concurrentEdits.edits.compactMap { (edit) -> TextEdit? in
119+
guard let (startLine, startColumn) = original.lineTable.lineAndUTF16ColumnOf(utf8Offset: edit.offset) else {
120+
logger.fault("Failed to convert offset \(edit.offset) into line:column")
121+
return nil
122+
}
123+
guard let (endLine, endColumn) = original.lineTable.lineAndUTF16ColumnOf(utf8Offset: edit.endOffset) else {
124+
logger.fault("Failed to convert offset \(edit.endOffset) into line:column")
125+
return nil
126+
}
127+
guard let newText = String(bytes: edit.replacement, encoding: .utf8) else {
128+
logger.fault("Failed to get String from UTF-8 bytes \(edit.replacement)")
129+
return nil
130+
}
164131

165132
return TextEdit(
166133
range: Position(line: startLine, utf16index: startColumn)..<Position(line: endLine, utf16index: endColumn),
167-
newText: edit.replacement
134+
newText: newText
168135
)
169136
}
170137
}

Tests/SourceKitLSPTests/FormattingTests.swift

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ final class FormattingTests: XCTestCase {
4444
XCTAssertEqual(
4545
edits,
4646
[
47-
TextEdit(range: positions["2️⃣"]..<positions["3️⃣"], newText: ""),
4847
TextEdit(range: Range(positions["1️⃣"]), newText: " "),
48+
TextEdit(range: positions["2️⃣"]..<positions["3️⃣"], newText: ""),
4949
TextEdit(range: Range(positions["4️⃣"]), newText: " "),
5050
TextEdit(range: Range(positions["5️⃣"]), newText: "\n"),
5151
]
@@ -247,4 +247,56 @@ final class FormattingTests: XCTestCase {
247247
]
248248
)
249249
}
250+
251+
func testMultiLineStringInsertion() async throws {
252+
try await SkipUnless.toolchainContainsSwiftFormat()
253+
let testClient = try await TestSourceKitLSPClient()
254+
let uri = DocumentURI.for(.swift)
255+
256+
let positions = testClient.openDocument(
257+
#"""
258+
_ = [
259+
Node(
260+
documentation: """
261+
1️⃣A
262+
2️⃣B
263+
3️⃣C
264+
4️⃣""",
265+
children: [
266+
Child(
267+
documentation: """
268+
5️⃣A
269+
6️⃣ 7️⃣\#("")
270+
8️⃣ 9️⃣ 🔟"""
271+
)
272+
]
273+
)
274+
]
275+
276+
"""#,
277+
uri: uri
278+
)
279+
280+
let response = try await testClient.send(
281+
DocumentFormattingRequest(
282+
textDocument: TextDocumentIdentifier(uri),
283+
options: FormattingOptions(tabSize: 2, insertSpaces: true)
284+
)
285+
)
286+
287+
let edits = try XCTUnwrap(response)
288+
XCTAssertEqual(
289+
edits,
290+
[
291+
TextEdit(range: Range(positions["1️⃣"]), newText: " "),
292+
TextEdit(range: Range(positions["2️⃣"]), newText: " "),
293+
TextEdit(range: Range(positions["3️⃣"]), newText: " "),
294+
TextEdit(range: Range(positions["4️⃣"]), newText: " "),
295+
TextEdit(range: Range(positions["5️⃣"]), newText: " "),
296+
TextEdit(range: Range(positions["6️⃣"]), newText: "\n"),
297+
TextEdit(range: positions["7️⃣"]..<positions["8️⃣"], newText: ""),
298+
TextEdit(range: positions["9️⃣"]..<positions["🔟"], newText: ""),
299+
]
300+
)
301+
}
250302
}

0 commit comments

Comments
 (0)