Skip to content

Commit 185114c

Browse files
authored
Merge pull request #301 from ahoppen/pr/normalize-edits
Clarify that edits passed to `IncrementalParseTransition` are concurrent and add function to transform sequential edits to concurrent
2 parents 49951b8 + a70ff2d commit 185114c

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)