blob: 17c80547ea1fabef2291ac7db46afb2a02916568 [file] [log] [blame]
// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:math' as math;
import 'package:analyzer_plugin/protocol/protocol_common.dart';
import 'package:collection/collection.dart';
/// A helper to merge [SourceFileEdit]s that may have been made in multiple
/// steps.
///
/// Edits to be merged must meet some criteria:
///
/// - Multiple [SourceFileEdit]s for the same file are assumed to be in order
/// and each one assumes any earlier [SourceFileEdit] has been applied.
/// - All edits within a [SourceFileEdit] are sorted from highest to lowest
/// offsets so that there is no ambiguity about how one edit may affect the
/// range of another.
class SourceChangeMerger {
/// A buffer that contains debug information about the re-ordering and merging
/// of edits.
///
/// This can be used in tests to provide more details about failures.
final StringBuffer? debugBuffer;
SourceChangeMerger({this.debugBuffer});
/// Merges a set of edits in-place.
List<SourceFileEdit> merge(List<SourceFileEdit> edits) {
var results = <SourceFileEdit>[];
for (var entry in edits.groupListsBy((edit) => edit.file).entries) {
var file = entry.key;
var editLists = entry.value;
// If this file only had a single set of edits, we don't need to do
// anything.
if (editLists.length == 1) {
results.add(editLists.single);
continue;
}
// Flatten all sets into a single set of edits. Because we know all
// lists and edits can be applied sequentially this is safe, however it
// can lose the property of all edits being ordered last-to-first which is
// something we will fix as part of sorting/merging.
var edits = editLists.expand((edits) => edits.edits).toList();
debugBuffer?.writeln(file);
_debugEdits('Original', edits);
_reorder(edits);
_debugEdits('Reordered', edits);
_merge(edits);
_debugEdits('Merged', edits);
results.add(
SourceFileEdit(file, editLists.first.fileStamp, edits: edits),
);
}
return results;
}
/// Writes [edits] into [debugBuffer] for debugging.
void _debugEdits(String editKind, List<SourceEdit> edits) {
var debugBuffer = this.debugBuffer;
if (debugBuffer == null) {
return;
}
debugBuffer.writeln('$editKind edits:');
for (var edit in edits) {
debugBuffer.writeln(' $edit');
}
debugBuffer.writeln();
}
/// Merges (in-place) any sequential edits that are overlapping or touching.
///
/// Overlapping/touching edits will be replaced with new edits that have the
/// same effect as applying the original edits sequentially to the source
/// string.
void _merge(List<SourceEdit> edits) {
for (var i = 0; i < edits.length - 1; i++) {
// "first" refers to position in the list (and order they were intended to
// be sequentially applied) and not necessarily offset/source order. Most
// edits will be in reverse order in the list.
var first = edits[i];
var second = edits[i + 1];
if (second.end < first.offset) {
// Since we know non-intersecting/touching edits are ordered correctly,
// the second one ending before the start of the first one means it does
// not require merging.
continue;
}
// Replace the first edit with a merged version and remove the second.
edits[i] = _mergeEdits(first, second);
edits.removeAt(i + 1);
i--; // Process this one again in case it also overlaps the next one.
}
}
/// Merges [first] and [second] into a new [SourceEdit] that has the same
/// effect as applying [first] then [second] sequentially to the source
/// string.
SourceEdit _mergeEdits(SourceEdit first, SourceEdit second) {
// "first" refers to position in the list (and order they were intended to
// be sequentially applied) and not necessarily offset/source order. Most
// edits will be in reverse order in the list.
var actualStart = math.min(first.offset, second.offset);
var actualEnd = math.max(first.end, second.end - first.delta);
var length = actualEnd - actualStart;
// The new replacement text is made up of three possible parts:
// 1. The start of first that is not replaced by second (prefix)
// 2. The text from second (middle)
// 3. The end of first that is not replaced by second (suffix)
var prefix =
second.offset > first.offset
? first.replacement.substring(0, second.offset - first.offset)
: '';
var middle = second.replacement;
var suffix =
second.end < first.offset + first.replacement.length
? first.replacement.substring(second.end - first.offset)
: '';
return SourceEdit(actualStart, length, '$prefix$middle$suffix');
}
/// Re-orders edits (in-place) so that they are from latest offset to earliest
/// offset except in the case where they touch or intersect.
///
/// Edits that are moved will be replaced by new edits with updated offsets
/// to preserve the same behaviour when applying edits sequentially.
///
/// Edits that touch or intersect will not be reordered, but will end up
/// adjacent because other edits will be moved around them.
void _reorder(List<SourceEdit> edits) {
// This is essentially an in-place insertion sort, but the edits are mutated
// as they are swapped to preserve behaviour.
for (var i = 1; i < edits.length; i++) {
var current = edits[i];
var j = i - 1;
// If the current edit starts after the j edit, we should swap
// it to bring it earlier.
while (j >= 0 && current.offset >= edits[j].end + edits[j].delta) {
// Because edits[j] will no longer be applied before us and we know it
// would have been applied earlier in the file, we need to adjust our
// offset by its delta.
// If we need to change the offset, we must create a new edit and not
// mutate the original.
current = SourceEdit(
current.offset - edits[j].delta,
current.length,
current.replacement,
);
edits[j + 1] = edits[j];
j--;
}
edits[j + 1] = current;
}
}
}
extension on SourceEdit {
int get delta => replacement.length - length;
}