| // 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:analysis_server/src/collections.dart'; | |
| 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; | |
| new({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) | |
| : ''; | |
| // Join the descriptions together. Even if Edit2 totally replaces Edit1, we | |
| // want to keep both to avoid showing something like | |
| // "Replace final with const" when the users code actually says "var" but | |
| // the first edit was replaced entirely by the second. | |
| var descriptions = <String>[?first.description, ?second.description]; | |
| var description = nullIfEmpty(descriptions)?.join(', '); | |
| return SourceEdit( | |
| actualStart, | |
| length, | |
| '$prefix$middle$suffix', | |
| description: description, | |
| ); | |
| } | |
| /// 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, | |
| description: current.description, | |
| ); | |
| edits[j + 1] = edits[j]; | |
| j--; | |
| } | |
| edits[j + 1] = current; | |
| } | |
| } | |
| } | |
| extension on SourceEdit { | |
| int get delta => replacement.length - length; | |
| } |