Skip to content

Commit 9a455c8

Browse files
DanTupCommit Queue
authored and
Commit Queue
committed
[analysis_server] Add a utility to merge SourceFileEdits from multiple passes of edits
This is work towards #47968. The fix processor may need to be called multiple times to fix all issues because some fixes may produce code that can be fixed by another (for example one inserting "final" and then another replacing it with "const"). In order to be able to provide multiple passes of edits to LSP, we need to merge them together because LSP's edits are not applied sequentially but must all represent valid locations in the original source file. This class is currently unused (besides tests). Change-Id: Ia006ff8044505c6d109f6480f7bcce4da4669cdb Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/304180 Reviewed-by: Brian Wilkerson <[email protected]> Commit-Queue: Brian Wilkerson <[email protected]>
1 parent c9557fe commit 9a455c8

File tree

3 files changed

+665
-0
lines changed

3 files changed

+665
-0
lines changed
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'dart:math' as math;
6+
7+
import 'package:analyzer_plugin/protocol/protocol_common.dart';
8+
import 'package:collection/collection.dart';
9+
10+
/// A helper to merge [SourceFileEdit]s that may have been made in multiple
11+
/// steps.
12+
///
13+
/// Edits to be merged must meet some criteria:
14+
///
15+
/// - Multiple [SourceFileEdit]s for the same file are assumed to be in order
16+
/// and each one assumes any earlier [SourceFileEdit] has been applied.
17+
/// - All edits within a [SourceFileEdit] are sorted from highest to lowest
18+
/// offsets so that there is no ambiguity about how one edit may affect the
19+
/// range of another.
20+
class SourceChangeMerger {
21+
/// A buffer that contains debug information about the re-ordering and merging
22+
/// of edits.
23+
///
24+
/// This can be used in tests to provide more details about failures.
25+
final StringBuffer? debugBuffer;
26+
27+
SourceChangeMerger({this.debugBuffer});
28+
29+
/// Merges a set of edits in-place.
30+
List<SourceFileEdit> merge(List<SourceFileEdit> edits) {
31+
final results = <SourceFileEdit>[];
32+
33+
for (final entry in edits.groupListsBy((edit) => edit.file).entries) {
34+
final file = entry.key;
35+
final editLists = entry.value;
36+
37+
// If this file only had a single set of edits, we don't need to do
38+
// anything.
39+
if (editLists.length == 1) {
40+
results.add(editLists.single);
41+
continue;
42+
}
43+
44+
// Flatten all sets into a single set of edits. Because we know all
45+
// lists and edits can be applied sequentially this is safe, however it
46+
// can lose the property of all edits being ordered last-to-first which is
47+
// something we will fix as part of sorting/merging.
48+
var edits = editLists.expand((edits) => edits.edits).toList();
49+
50+
debugBuffer?.writeln(file);
51+
_debugEdits('Original', edits);
52+
53+
_reorder(edits);
54+
_debugEdits('Reordered', edits);
55+
56+
_merge(edits);
57+
_debugEdits('Merged', edits);
58+
59+
results
60+
.add(SourceFileEdit(file, editLists.first.fileStamp, edits: edits));
61+
}
62+
63+
return results;
64+
}
65+
66+
/// Writes [edits] into [debugBuffer] for debugging.
67+
void _debugEdits(String editKind, List<SourceEdit> edits) {
68+
final debugBuffer = this.debugBuffer;
69+
if (debugBuffer == null) {
70+
return;
71+
}
72+
73+
debugBuffer.writeln('$editKind edits:');
74+
for (final edit in edits) {
75+
debugBuffer.writeln(' $edit');
76+
}
77+
debugBuffer.writeln('');
78+
}
79+
80+
/// Merges (in-place) any sequential edits that are overlapping or touching.
81+
///
82+
/// Overlapping/touching edits will be replaced with new edits that have the
83+
/// same effect as applying the original edits sequentially to the source
84+
/// string.
85+
void _merge(List<SourceEdit> edits) {
86+
for (var i = 0; i < edits.length - 1; i++) {
87+
// "first" refers to position in the list (and order they were intended to
88+
// be sequentially applied) and not necessarily offset/source order. Most
89+
// edits will be in reverse order in the list.
90+
final first = edits[i];
91+
final second = edits[i + 1];
92+
93+
if (second.end < first.offset) {
94+
// Since we know non-intersecting/touching edits are ordered correctly,
95+
// the second one ending before the start of the first one means it does
96+
// not require merging.
97+
continue;
98+
}
99+
100+
// Replace the first edit with a merged version and remove the second.
101+
edits[i] = _mergeEdits(first, second);
102+
edits.removeAt(i + 1);
103+
i--; // Process this one again in case it also overlaps the next one.
104+
}
105+
}
106+
107+
/// Merges [first] and [second] into a new [SourceEdit] that has the same
108+
/// effect as applying [first] then [second] sequentially to the source
109+
/// string.
110+
SourceEdit _mergeEdits(SourceEdit first, SourceEdit second) {
111+
// "first" refers to position in the list (and order they were intended to
112+
// be sequentially applied) and not necessarily offset/source order. Most
113+
// edits will be in reverse order in the list.
114+
115+
final actualStart = math.min(first.offset, second.offset);
116+
final actualEnd = math.max(first.end, second.end - first.delta);
117+
final length = actualEnd - actualStart;
118+
119+
// The new replacement text is made up of three possible parts:
120+
// 1. The start of first that is not replaced by second (prefix)
121+
// 2. The text from second (middle)
122+
// 3. The end of first that is not replaced by second (suffix)
123+
final prefix = second.offset > first.offset
124+
? first.replacement.substring(0, second.offset - first.offset)
125+
: '';
126+
final middle = second.replacement;
127+
final suffix = second.end < first.offset + first.replacement.length
128+
? first.replacement.substring(second.end - first.offset)
129+
: '';
130+
131+
return SourceEdit(actualStart, length, '$prefix$middle$suffix');
132+
}
133+
134+
/// Re-orders edits (in-place) so that they are from latest offset to earliest
135+
/// offset except in the case where they touch or intersect.
136+
///
137+
/// Edits that are moved will be replaced by new edits with updated offsets
138+
/// to preserve the same behaviour when applying edits sequentially.
139+
///
140+
/// Edits that touch or intersect will not be reordered, but will end up
141+
/// adjacent because other edits will be moved around them.
142+
void _reorder(List<SourceEdit> edits) {
143+
// This is essentially an in-place insertion sort, but the edits are mutated
144+
// as they are swapped to preserve behaviour.
145+
for (var i = 1; i < edits.length; i++) {
146+
var current = edits[i];
147+
var j = i - 1;
148+
// If the current edit starts after the j edit, we should swap
149+
// it to bring it earlier.
150+
while (j >= 0 && current.offset >= edits[j].end + edits[j].delta) {
151+
// Because edits[j] will no longer be applied before us and we know it
152+
// would have been applied earlier in the file, we need to adjust our
153+
// offset by its delta.
154+
// If we need to change the offset, we must create a new edit and not
155+
// mutate the original.
156+
current = SourceEdit(
157+
current.offset - edits[j].delta,
158+
current.length,
159+
current.replacement,
160+
);
161+
edits[j + 1] = edits[j];
162+
j--;
163+
}
164+
edits[j + 1] = current;
165+
}
166+
}
167+
}
168+
169+
extension on SourceEdit {
170+
int get delta => replacement.length - length;
171+
}

0 commit comments

Comments
 (0)