Skip to content

Commit a70ff2d

Browse files
committed
Clarify that edits passed to IncrementalParseTransition are concurrent and add function to transform sequential edits to concurrent
To clarify that the edits passed to `IncrementalParseTransition` are applied concurrently, introduce a new `ConcurrentEdit` type that provides the guarantee and allows translation of sequentially applied edits to the expected concurrent form. Fixes rdar://72848263
1 parent df40ff8 commit a70ff2d

File tree

6 files changed

+443
-17
lines changed

6 files changed

+443
-17
lines changed

Changelog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
Note: This is in reverse chronological order, so newer entries are added to the top.
44

5+
## `main`
6+
7+
* To clarify that the edits passed to `IncrementalParseTransition` are applied concurrently, introduce a new `ConcurrentEdit` type that provides the guarantee and allows translation of sequentially applied edits to the expected concurrent form.
8+
59
## Swift 5.3
610

711
* Introduced `FunctionCallExprSyntax.additionalTrailingClosures` property with type `MultipleTrailingClosureElementListSyntax?` for supporting [SE-0279 Multiple Trailing Closures](https://github.com/apple/swift-evolution/blob/main/proposals/0279-multiple-trailing-closures.md).

Sources/SwiftSyntax/IncrementalParseTransition.swift

Lines changed: 120 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ public final class IncrementalParseReusedNodeCollector:
4545
/// occurred since it was created.
4646
public final class IncrementalParseTransition {
4747
fileprivate let previousTree: SourceFileSyntax
48-
fileprivate let edits: [SourceEdit]
48+
fileprivate let edits: ConcurrentEdits
4949
fileprivate let reusedDelegate: IncrementalParseReusedNodeDelegate?
5050

5151
/// - Parameters:
@@ -57,19 +57,122 @@ public final class IncrementalParseTransition {
5757
/// 2. should be in increasing source offset order.
5858
/// - reusedNodeDelegate: Optional delegate to accept information about the
5959
/// reused regions and nodes.
60+
@available(*, deprecated, message: "Use the initializer taking 'ConcurrentEdits' instead")
61+
public convenience init(previousTree: SourceFileSyntax,
62+
edits: [SourceEdit],
63+
reusedNodeDelegate: IncrementalParseReusedNodeDelegate? = nil) {
64+
self.init(
65+
previousTree: previousTree,
66+
edits: ConcurrentEdits(concurrent: edits),
67+
reusedNodeDelegate: reusedNodeDelegate
68+
)
69+
}
70+
71+
/// - Parameters:
72+
/// - previousTree: The previous tree to do lookups on.
73+
/// - edits: The edits that have occurred since the last parse that resulted
74+
/// in the new source that is about to be parsed.
75+
/// - reusedNodeDelegate: Optional delegate to accept information about the
76+
/// reused regions and nodes.
6077
public init(previousTree: SourceFileSyntax,
61-
edits: [SourceEdit],
78+
edits: ConcurrentEdits,
6279
reusedNodeDelegate: IncrementalParseReusedNodeDelegate? = nil) {
63-
assert(IncrementalParseTransition.isEditArrayValid(edits))
6480
self.previousTree = previousTree
6581
self.edits = edits
6682
self.reusedDelegate = reusedNodeDelegate
6783
}
84+
}
85+
86+
fileprivate extension Sequence where Element: Comparable {
87+
var isSorted: Bool {
88+
return zip(self, self.dropFirst()).allSatisfy({ $0.0 < $0.1 })
89+
}
90+
}
6891

69-
/// Checks the requirements for the edits array to:
70-
/// 1. not be overlapping.
71-
/// 2. should be in increasing source offset order.
72-
public static func isEditArrayValid(_ edits: [SourceEdit]) -> Bool {
92+
/// Edits that are applied **simultaneously**. That is, the offsets of all edits
93+
/// refer to the original string and are not shifted by previous edits. For
94+
/// example applying
95+
/// - insert 'x' at offset 0
96+
/// - insert 'y' at offset 1
97+
/// - insert 'z' at offset 2
98+
/// to '012345' results in 'x0y1z2345'.
99+
///
100+
/// The raw `edits` of this struct are guaranteed to
101+
/// 1. not be overlapping.
102+
/// 2. be in increasing source offset order.
103+
public struct ConcurrentEdits {
104+
/// The raw concurrent edits. Are guaranteed to satisfy the requirements
105+
/// stated above.
106+
public let edits: [SourceEdit]
107+
108+
/// Initialize this struct from edits that are already in a concurrent form
109+
/// and are guaranteed to satisfy the requirements posed above.
110+
public init(concurrent: [SourceEdit]) {
111+
precondition(Self.isValidConcurrentEditArray(concurrent))
112+
self.edits = concurrent
113+
}
114+
115+
/// Create concurrent from a set of sequential edits. Sequential edits are
116+
/// applied one after the other. Applying the first edit results in an
117+
/// intermediate string to which the second edit is applied etc. For example
118+
/// applying
119+
/// - insert 'x' at offset 0
120+
/// - insert 'y' at offset 1
121+
/// - insert 'z' at offset 2
122+
/// to '012345' results in 'xyz012345'.
123+
124+
public init(fromSequential sequentialEdits: [SourceEdit]) {
125+
self.init(concurrent: Self.translateSequentialEditsToConcurrentEdits(sequentialEdits))
126+
}
127+
128+
/// Construct a concurrent edits struct from a single edit. For a single edit,
129+
/// there is no differentiation between being it being applied concurrently
130+
/// or sequentially.
131+
public init(_ single: SourceEdit) {
132+
self.init(concurrent: [single])
133+
}
134+
135+
private static func translateSequentialEditsToConcurrentEdits(
136+
_ edits: [SourceEdit]
137+
) -> [SourceEdit] {
138+
var concurrentEdits: [SourceEdit] = []
139+
for editToAdd in edits {
140+
var editToAdd = editToAdd
141+
var editIndiciesMergedWithNewEdit: [Int] = []
142+
for (index, existingEdit) in concurrentEdits.enumerated() {
143+
if existingEdit.replacementRange.intersectsOrTouches(editToAdd.range) {
144+
let intersectionLength =
145+
existingEdit.replacementRange.intersected(editToAdd.range).length
146+
editToAdd = SourceEdit(
147+
offset: Swift.min(existingEdit.offset, editToAdd.offset),
148+
length: existingEdit.length + editToAdd.length - intersectionLength,
149+
replacementLength: existingEdit.replacementLength +
150+
editToAdd.replacementLength - intersectionLength
151+
)
152+
editIndiciesMergedWithNewEdit.append(index)
153+
} else if existingEdit.offset < editToAdd.endOffset {
154+
editToAdd = SourceEdit(
155+
offset: editToAdd.offset - existingEdit.replacementLength +
156+
existingEdit.length,
157+
length: editToAdd.length,
158+
replacementLength: editToAdd.replacementLength
159+
)
160+
}
161+
}
162+
assert(editIndiciesMergedWithNewEdit.isSorted)
163+
for indexToRemove in editIndiciesMergedWithNewEdit.reversed() {
164+
concurrentEdits.remove(at: indexToRemove)
165+
}
166+
let insertPos = concurrentEdits.firstIndex(where: { edit in
167+
editToAdd.endOffset <= edit.offset
168+
}) ?? concurrentEdits.count
169+
concurrentEdits.insert(editToAdd, at: insertPos)
170+
assert(ConcurrentEdits.isValidConcurrentEditArray(concurrentEdits))
171+
}
172+
return concurrentEdits
173+
}
174+
175+
private static func isValidConcurrentEditArray(_ edits: [SourceEdit]) -> Bool {
73176
// Not quite sure if we should disallow creating an `IncrementalParseTransition`
74177
// object without edits but there doesn't seem to be much benefit if we do,
75178
// and there are 'lit' tests that want to test incremental re-parsing without edits.
@@ -87,6 +190,11 @@ public final class IncrementalParseTransition {
87190
}
88191
return true
89192
}
193+
194+
/// **Public for testing purposes only**
195+
public static func _isValidConcurrentEditArray(_ edits: [SourceEdit]) -> Bool {
196+
return isValidConcurrentEditArray(edits)
197+
}
90198
}
91199

92200
/// Provides a mechanism for the parser to skip regions of an incrementally
@@ -100,7 +208,7 @@ internal struct IncrementalParseLookup {
100208
self.cursor = .init(root: transition.previousTree.data.absoluteRaw)
101209
}
102210

103-
fileprivate var edits: [SourceEdit] {
211+
fileprivate var edits: ConcurrentEdits {
104212
return transition.edits
105213
}
106214

@@ -160,7 +268,7 @@ internal struct IncrementalParseLookup {
160268

161269
// Fast path check: if parser is past all the edits then any matching node
162270
// can be re-used.
163-
if !edits.isEmpty && edits.last!.range.endOffset < node.position.utf8Offset {
271+
if !edits.edits.isEmpty && edits.edits.last!.range.endOffset < node.position.utf8Offset {
164272
return true
165273
}
166274

@@ -172,7 +280,7 @@ internal struct IncrementalParseLookup {
172280
if let nextSibling = cursor.nextSibling {
173281
// Fast path check: if next sibling is before all the edits then we can
174282
// re-use the node.
175-
if !edits.isEmpty && edits.first!.range.offset > nextSibling.endPosition.utf8Offset {
283+
if !edits.edits.isEmpty && edits.edits.first!.range.offset > nextSibling.endPosition.utf8Offset {
176284
return true
177285
}
178286
if let nextToken = nextSibling.raw.firstPresentToken {
@@ -182,7 +290,7 @@ internal struct IncrementalParseLookup {
182290
let nodeAffectRange = ByteSourceRange(offset: node.position.utf8Offset,
183291
length: (node.raw.totalLength + nextLeafNodeLength).utf8Length)
184292

185-
for edit in edits {
293+
for edit in edits.edits {
186294
// Check if this node or the trivia of the next node has been edited. If
187295
// it has, we cannot reuse it.
188296
if edit.range.offset > nodeAffectRange.endOffset {
@@ -199,7 +307,7 @@ internal struct IncrementalParseLookup {
199307

200308
fileprivate func translateToPreEditOffset(_ postEditOffset: Int) -> Int? {
201309
var offset = postEditOffset
202-
for edit in edits {
310+
for edit in edits.edits {
203311
if edit.range.offset > offset {
204312
// Remaining edits doesn't affect the position. (Edits are sorted)
205313
break

Sources/SwiftSyntax/Utils.swift

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,17 +49,33 @@ public struct ByteSourceRange: Equatable {
4949
}
5050
}
5151

52-
public struct SourceEdit {
52+
public struct SourceEdit: Equatable {
5353
/// The byte range of the original source buffer that the edit applies to.
5454
public let range: ByteSourceRange
5555
/// The length of the edit replacement in UTF8 bytes.
5656
public let replacementLength: Int
5757

58+
public var offset: Int { return range.offset }
59+
60+
public var length: Int { return range.length }
61+
62+
public var endOffset: Int { return range.endOffset }
63+
64+
/// After the edit has been applied the range of the replacement text.
65+
public var replacementRange: ByteSourceRange {
66+
return ByteSourceRange(offset: offset, length: replacementLength)
67+
}
68+
5869
public init(range: ByteSourceRange, replacementLength: Int) {
5970
self.range = range
6071
self.replacementLength = replacementLength
6172
}
6273

74+
public init(offset: Int, length: Int, replacementLength: Int) {
75+
self.range = ByteSourceRange(offset: offset, length: length)
76+
self.replacementLength = replacementLength
77+
}
78+
6379
public func intersectsOrTouchesRange(_ other: ByteSourceRange) -> Bool {
6480
return self.range.intersectsOrTouches(other)
6581
}

Sources/lit-test-helper/main.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,8 +289,11 @@ func performParseIncremental(args: CommandLineArguments) throws {
289289
let preEditTree = try SyntaxParser.parse(preEditURL)
290290
let edits = try parseIncrementalEditArguments(args: args)
291291
let regionCollector = IncrementalParseReusedNodeCollector()
292-
let editTransition = IncrementalParseTransition(previousTree: preEditTree,
293-
edits: edits, reusedNodeDelegate: regionCollector)
292+
let editTransition = IncrementalParseTransition(
293+
previousTree: preEditTree,
294+
edits: ConcurrentEdits(concurrent: edits),
295+
reusedNodeDelegate: regionCollector
296+
)
294297

295298
let postEditText = try String(contentsOf: postEditURL)
296299
let postEditTree =

Tests/SwiftSyntaxTest/IncrementalParsingTests.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ public class IncrementalParsingTests: XCTestCase {
1010

1111
var tree = try! SyntaxParser.parse(source: original)
1212
let sourceEdit = SourceEdit(range: ByteSourceRange(offset: step.1.0, length: step.1.1), replacementLength: step.1.2.utf8.count)
13-
let lookup = IncrementalParseTransition(previousTree: tree, edits: [sourceEdit])
13+
let lookup = IncrementalParseTransition(previousTree: tree, edits: ConcurrentEdits(sourceEdit))
1414
tree = try! SyntaxParser.parse(source: step.0, parseTransition: lookup)
1515
XCTAssertEqual("\(tree)", step.0)
1616
}
@@ -23,7 +23,7 @@ public class IncrementalParsingTests: XCTestCase {
2323
let origTree = try! SyntaxParser.parse(source: original)
2424
let sourceEdit = SourceEdit(range: ByteSourceRange(offset: step.1.0, length: step.1.1), replacementLength: step.1.2.utf8.count)
2525
let reusedNodeCollector = IncrementalParseReusedNodeCollector()
26-
let transition = IncrementalParseTransition(previousTree: origTree, edits: [sourceEdit], reusedNodeDelegate: reusedNodeCollector)
26+
let transition = IncrementalParseTransition(previousTree: origTree, edits: ConcurrentEdits(sourceEdit), reusedNodeDelegate: reusedNodeCollector)
2727
let newTree = try! SyntaxParser.parse(source: step.0, parseTransition: transition)
2828
XCTAssertEqual("\(newTree)", step.0)
2929

0 commit comments

Comments
 (0)