| // Copyright (c) 2017, 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:collection'; |
| |
| import 'package:analyzer/dart/analysis/results.dart'; |
| import 'package:analyzer/dart/analysis/session.dart'; |
| import 'package:analyzer/exception/exception.dart'; |
| import 'package:analyzer/file_system/file_system.dart'; |
| import 'package:analyzer/src/generated/source.dart'; |
| import 'package:analyzer_plugin/protocol/protocol_common.dart'; |
| import 'package:analyzer_plugin/src/utilities/change_builder/change_builder_dart.dart'; |
| import 'package:analyzer_plugin/src/utilities/change_builder/change_builder_yaml.dart'; |
| import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart'; |
| import 'package:analyzer_plugin/utilities/change_builder/change_builder_dart.dart'; |
| import 'package:analyzer_plugin/utilities/change_builder/change_builder_yaml.dart'; |
| import 'package:analyzer_plugin/utilities/change_builder/change_workspace.dart'; |
| import 'package:yaml/yaml.dart'; |
| |
| /// A builder used to build a [SourceChange]. |
| class ChangeBuilderImpl implements ChangeBuilder { |
| /// The workspace in which the change builder should operate. |
| final ChangeWorkspace workspace; |
| |
| /// The end-of-line marker used in the file being edited, or `null` if the |
| /// default marker should be used. |
| final String? eol; |
| |
| /// A table mapping group ids to the associated linked edit groups. |
| final Map<String, LinkedEditGroup> _linkedEditGroups = |
| <String, LinkedEditGroup>{}; |
| |
| /// The source change selection or `null` if none. |
| Position? _selection; |
| |
| /// The range of the selection for the change being built, or `null` if there |
| /// is no selection. |
| SourceRange? _selectionRange; |
| |
| /// The set of [Position]s that belong to the current [EditBuilderImpl] and |
| /// should not be updated in result of inserting this builder. |
| final Set<Position> _lockedPositions = HashSet<Position>.identity(); |
| |
| /// A map of absolute normalized path to generic file edit builders. |
| final Map<String, FileEditBuilderImpl> _genericFileEditBuilders = {}; |
| |
| /// A map of absolute normalized path to Dart file edit builders. |
| final Map<String, DartFileEditBuilderImpl> _dartFileEditBuilders = {}; |
| |
| /// A map of absolute normalized path to YAML file edit builders. |
| final Map<String, YamlFileEditBuilderImpl> _yamlFileEditBuilders = {}; |
| |
| /// Initialize a newly created change builder. If the builder will be used to |
| /// create changes for Dart files, then either a [session] or a [workspace] |
| /// must be provided (but not both). |
| ChangeBuilderImpl( |
| {AnalysisSession? session, ChangeWorkspace? workspace, this.eol}) |
| : assert(session == null || workspace == null), |
| workspace = workspace ?? _SingleSessionWorkspace(session!); |
| |
| /// Return a hash value that will change when new edits have been added to |
| /// this builder. |
| int get changeHash { |
| // The hash value currently ignores edits to import directives because |
| // finalizing the builders needs to happen exactly once and this getter |
| // needs to be invoked repeatedly. |
| // |
| // In addition, we should consider implementing our own hash function for |
| // file edits because the `hashCode` defined for them might not be |
| // sufficient to detect all changes to the list of edits. |
| return Object.hashAll([ |
| for (var builder in _genericFileEditBuilders.values) |
| if (builder.hasEdits) builder.fileEdit, |
| for (var builder in _dartFileEditBuilders.values) |
| if (builder.hasEdits) builder.fileEdit, |
| for (var builder in _yamlFileEditBuilders.values) |
| if (builder.hasEdits) builder.fileEdit, |
| ]); |
| } |
| |
| @override |
| SourceRange? get selectionRange => _selectionRange; |
| |
| @override |
| SourceChange get sourceChange { |
| var change = SourceChange(''); |
| for (var builder in _genericFileEditBuilders.values) { |
| if (builder.hasEdits) { |
| change.addFileEdit(builder.fileEdit); |
| builder.finalize(); |
| } |
| } |
| for (var builder in _dartFileEditBuilders.values) { |
| if (builder.hasEdits) { |
| change.addFileEdit(builder.fileEdit); |
| builder.finalize(); |
| } |
| } |
| for (var builder in _yamlFileEditBuilders.values) { |
| if (builder.hasEdits) { |
| change.addFileEdit(builder.fileEdit); |
| builder.finalize(); |
| } |
| } |
| _linkedEditGroups.forEach((String name, LinkedEditGroup group) { |
| change.addLinkedEditGroup(group); |
| }); |
| var selection = _selection; |
| if (selection != null) { |
| change.selection = selection; |
| } |
| return change; |
| } |
| |
| @override |
| Future<void> addDartFileEdit( |
| String path, void Function(DartFileEditBuilder builder) buildFileEdit, |
| {ImportPrefixGenerator? importPrefixGenerator}) async { |
| if (_genericFileEditBuilders.containsKey(path)) { |
| throw StateError("Can't create both a generic file edit and a dart file " |
| 'edit for the same file'); |
| } |
| if (_yamlFileEditBuilders.containsKey(path)) { |
| throw StateError("Can't create both a yaml file edit and a dart file " |
| 'edit for the same file'); |
| } |
| var builder = _dartFileEditBuilders[path]; |
| if (builder == null) { |
| builder = await _createDartFileEditBuilder(path); |
| if (builder != null) { |
| // It's not currently supported to call this method twice concurrently |
| // for the same file as two builder may be produced because of the above |
| // `await` so detect this and throw to avoid losing edits. |
| if (_dartFileEditBuilders.containsKey(path)) { |
| throw StateError( |
| "Can't add multiple edits concurrently for the same file"); |
| } |
| _dartFileEditBuilders[path] = builder; |
| } |
| } |
| if (builder != null) { |
| builder.importPrefixGenerator = importPrefixGenerator; |
| buildFileEdit(builder); |
| } |
| } |
| |
| @override |
| Future<void> addGenericFileEdit( |
| String path, void Function(FileEditBuilder builder) buildFileEdit) async { |
| if (_dartFileEditBuilders.containsKey(path)) { |
| throw StateError("Can't create both a dart file edit and a generic file " |
| 'edit for the same file'); |
| } |
| if (_yamlFileEditBuilders.containsKey(path)) { |
| throw StateError("Can't create both a yaml file edit and a generic file " |
| 'edit for the same file'); |
| } |
| var builder = _genericFileEditBuilders[path]; |
| if (builder == null) { |
| builder = FileEditBuilderImpl(this, path, 0); |
| _genericFileEditBuilders[path] = builder; |
| } |
| buildFileEdit(builder); |
| } |
| |
| @override |
| Future<void> addYamlFileEdit(String path, |
| void Function(YamlFileEditBuilder builder) buildFileEdit) async { |
| if (_dartFileEditBuilders.containsKey(path)) { |
| throw StateError("Can't create both a dart file edit and a yaml file " |
| 'edit for the same file'); |
| } |
| if (_genericFileEditBuilders.containsKey(path)) { |
| throw StateError("Can't create both a generic file edit and a yaml file " |
| 'edit for the same file'); |
| } |
| var builder = _yamlFileEditBuilders[path]; |
| if (builder == null) { |
| builder = YamlFileEditBuilderImpl( |
| this, |
| path, |
| loadYamlDocument( |
| workspace |
| .getSession(path)! |
| .resourceProvider |
| .getFile(path) |
| .readAsStringSync(), |
| recover: true), |
| 0); |
| _yamlFileEditBuilders[path] = builder; |
| } |
| buildFileEdit(builder); |
| } |
| |
| @override |
| ChangeBuilder copy() { |
| var copy = ChangeBuilderImpl(workspace: workspace, eol: eol); |
| for (var entry in _linkedEditGroups.entries) { |
| copy._linkedEditGroups[entry.key] = _copyLinkedEditGroup(entry.value); |
| } |
| var selection = _selection; |
| if (selection != null) { |
| copy._selection = _copyPosition(selection); |
| } |
| copy._selectionRange = _selectionRange; |
| copy._lockedPositions.addAll(_lockedPositions); |
| for (var entry in _genericFileEditBuilders.entries) { |
| copy._genericFileEditBuilders[entry.key] = entry.value.copyWith(copy); |
| } |
| // |
| // The file edit builders for libraries (those whose [libraryChangeBuilder] |
| // is `null`) are copied first so that the copies exist when we copy the |
| // builders for parts and the structure can be preserved. |
| // |
| var editBuilderMap = <DartFileEditBuilderImpl, DartFileEditBuilderImpl>{}; |
| for (var entry in _dartFileEditBuilders.entries) { |
| var oldBuilder = entry.value; |
| if (oldBuilder.libraryChangeBuilder == null) { |
| var newBuilder = oldBuilder.copyWith(copy); |
| copy._dartFileEditBuilders[entry.key] = newBuilder; |
| editBuilderMap[oldBuilder] = newBuilder; |
| } |
| } |
| for (var entry in _dartFileEditBuilders.entries) { |
| var oldBuilder = entry.value; |
| if (oldBuilder.libraryChangeBuilder != null) { |
| var newBuilder = |
| oldBuilder.copyWith(copy, editBuilderMap: editBuilderMap); |
| copy._dartFileEditBuilders[entry.key] = newBuilder; |
| } |
| } |
| for (var entry in _yamlFileEditBuilders.entries) { |
| copy._yamlFileEditBuilders[entry.key] = entry.value.copyWith(copy); |
| } |
| return copy; |
| } |
| |
| /// Return the linked edit group with the given [groupName], creating it if it |
| /// did not already exist. |
| LinkedEditGroup getLinkedEditGroup(String groupName) { |
| var group = _linkedEditGroups[groupName]; |
| if (group == null) { |
| group = LinkedEditGroup.empty(); |
| _linkedEditGroups[groupName] = group; |
| } |
| return group; |
| } |
| |
| @override |
| bool hasEditsFor(String path) { |
| return _dartFileEditBuilders.containsKey(path) || |
| _genericFileEditBuilders.containsKey(path) || |
| _yamlFileEditBuilders.containsKey(path); |
| } |
| |
| @override |
| void setSelection(Position position) { |
| _selection = position; |
| } |
| |
| /// Return a copy of the linked edit [group]. |
| LinkedEditGroup _copyLinkedEditGroup(LinkedEditGroup group) { |
| return LinkedEditGroup(group.positions.map(_copyPosition).toList(), |
| group.length, group.suggestions.toList()); |
| } |
| |
| /// Return a copy of the [position]. |
| Position _copyPosition(Position position) { |
| return Position(position.file, position.offset); |
| } |
| |
| /// Create and return a [DartFileEditBuilder] that can be used to build edits |
| /// to the Dart file with the given [path]. |
| Future<DartFileEditBuilderImpl?> _createDartFileEditBuilder( |
| String? path) async { |
| if (path == null || !(workspace.containsFile(path) ?? false)) { |
| return null; |
| } |
| |
| var session = workspace.getSession(path); |
| var result = await session?.getResolvedUnit(path); |
| if (result is! ResolvedUnitResult) { |
| throw AnalysisException('Cannot analyze "$path"'); |
| } |
| var timeStamp = result.exists ? 0 : -1; |
| |
| var declaredUnit = result.unit.declaredElement; |
| var libraryUnit = declaredUnit?.library.definingCompilationUnit; |
| |
| DartFileEditBuilderImpl? libraryEditBuilder; |
| if (libraryUnit != null && libraryUnit != declaredUnit) { |
| // If the receiver is a part file builder, then proactively cache the |
| // library file builder so that imports can be finalized synchronously. |
| await addDartFileEdit(libraryUnit.source.fullName, (builder) { |
| libraryEditBuilder = builder as DartFileEditBuilderImpl; |
| }); |
| } |
| |
| return DartFileEditBuilderImpl(this, result, timeStamp, libraryEditBuilder); |
| } |
| |
| void _setSelectionRange(SourceRange range) { |
| _selectionRange = range; |
| } |
| |
| /// Update the offsets of any positions that occur at or after the given |
| /// [offset] such that the positions are offset by the given [delta]. |
| /// Positions occur in linked edit groups and as the post-change selection. |
| void _updatePositions(int offset, int delta) { |
| void _updatePosition(Position position) { |
| if (position.offset >= offset && !_lockedPositions.contains(position)) { |
| position.offset = position.offset + delta; |
| } |
| } |
| |
| for (var group in _linkedEditGroups.values) { |
| for (var position in group.positions) { |
| _updatePosition(position); |
| } |
| } |
| var selection = _selection; |
| if (selection != null) { |
| _updatePosition(selection); |
| } |
| } |
| } |
| |
| /// A builder used to build a [SourceEdit] as part of a [SourceFileEdit]. |
| class EditBuilderImpl implements EditBuilder { |
| /// The builder being used to create the source file edit of which the source |
| /// edit will be a part. |
| final FileEditBuilderImpl fileEditBuilder; |
| |
| /// The offset of the region being replaced. |
| final int offset; |
| |
| /// The length of the region being replaced. |
| final int length; |
| |
| /// The range of the selection for the change being built, or `null` if the |
| /// selection is not inside the change being built. |
| SourceRange? _selectionRange; |
| |
| /// The end-of-line marker used in the file being edited, or `null` if the |
| /// default marker should be used. |
| String? _eol; |
| |
| /// The buffer in which the content of the edit is being composed. |
| final StringBuffer _buffer = StringBuffer(); |
| |
| /// Initialize a newly created builder to build a source edit. |
| EditBuilderImpl(this.fileEditBuilder, this.offset, this.length) { |
| _eol = fileEditBuilder.changeBuilder.eol; |
| } |
| |
| /// Create and return an edit representing the replacement of a region of the |
| /// file with the accumulated text. |
| SourceEdit get sourceEdit => SourceEdit(offset, length, _buffer.toString()); |
| |
| @override |
| void addLinkedEdit(String groupName, |
| void Function(LinkedEditBuilder builder) buildLinkedEdit) { |
| var builder = createLinkedEditBuilder(); |
| var start = offset + _buffer.length; |
| try { |
| buildLinkedEdit(builder); |
| } finally { |
| var end = offset + _buffer.length; |
| var length = end - start; |
| if (length != 0) { |
| var position = Position(fileEditBuilder.fileEdit.file, start); |
| fileEditBuilder.changeBuilder._lockedPositions.add(position); |
| var group = fileEditBuilder.changeBuilder.getLinkedEditGroup(groupName); |
| group.addPosition(position, length); |
| for (var suggestion in builder.suggestions) { |
| group.addSuggestion(suggestion); |
| } |
| } |
| } |
| } |
| |
| @override |
| void addSimpleLinkedEdit(String groupName, String text, |
| {LinkedEditSuggestionKind? kind, List<String>? suggestions}) { |
| addLinkedEdit(groupName, (LinkedEditBuilder builder) { |
| builder.write(text); |
| if (kind != null && suggestions != null) { |
| for (var suggestion in suggestions) { |
| builder.addSuggestion(kind, suggestion); |
| } |
| } else if (kind != null || suggestions != null) { |
| throw ArgumentError( |
| 'Either both kind and suggestions must be provided or neither.'); |
| } |
| }); |
| } |
| |
| LinkedEditBuilderImpl createLinkedEditBuilder() { |
| return LinkedEditBuilderImpl(this); |
| } |
| |
| @override |
| void selectAll(void Function() writer) { |
| var rangeOffset = _buffer.length; |
| writer(); |
| var rangeLength = _buffer.length - rangeOffset; |
| _selectionRange = SourceRange(offset + rangeOffset, rangeLength); |
| } |
| |
| @override |
| void selectHere() { |
| _selectionRange = SourceRange(offset + _buffer.length, 0); |
| } |
| |
| @override |
| void write(String string) { |
| _buffer.write(string); |
| } |
| |
| @override |
| void writeln([String? string]) { |
| if (string != null) { |
| _buffer.write(string); |
| } |
| if (_eol == null) { |
| _buffer.writeln(); |
| } else { |
| _buffer.write(_eol); |
| } |
| } |
| } |
| |
| /// A builder used to build a [SourceFileEdit] within a [SourceChange]. |
| class FileEditBuilderImpl implements FileEditBuilder { |
| /// The builder being used to create the source change of which the source |
| /// file edit will be a part. |
| final ChangeBuilderImpl changeBuilder; |
| |
| /// The source file edit that is being built. |
| final SourceFileEdit fileEdit; |
| |
| /// Initialize a newly created builder to build a source file edit within the |
| /// change being built by the given [changeBuilder]. The file being edited has |
| /// the given absolute [path] and [timeStamp]. |
| FileEditBuilderImpl(this.changeBuilder, String path, int timeStamp) |
| : fileEdit = SourceFileEdit(path, timeStamp); |
| |
| /// Return `true` if this builder has edits to be applied. |
| bool get hasEdits => fileEdit.edits.isNotEmpty; |
| |
| @override |
| void addDeletion(SourceRange range) { |
| if (range.length > 0) { |
| var builder = createEditBuilder(range.offset, range.length); |
| _addEditBuilder(builder); |
| } |
| } |
| |
| @override |
| void addInsertion(int offset, void Function(EditBuilder builder) buildEdit) { |
| var builder = createEditBuilder(offset, 0); |
| try { |
| buildEdit(builder); |
| } finally { |
| _addEditBuilder(builder); |
| } |
| } |
| |
| @override |
| void addLinkedPosition(SourceRange range, String groupName) { |
| var group = changeBuilder.getLinkedEditGroup(groupName); |
| var position = |
| Position(fileEdit.file, range.offset + _deltaToOffset(range.offset)); |
| group.addPosition(position, range.length); |
| } |
| |
| @override |
| void addReplacement( |
| SourceRange range, void Function(EditBuilder builder) buildEdit) { |
| var builder = createEditBuilder(range.offset, range.length); |
| try { |
| buildEdit(builder); |
| } finally { |
| _addEditBuilder(builder); |
| } |
| } |
| |
| @override |
| void addSimpleInsertion(int offset, String text) { |
| var builder = createEditBuilder(offset, 0); |
| try { |
| builder.write(text); |
| } finally { |
| _addEditBuilder(builder); |
| } |
| } |
| |
| @override |
| void addSimpleReplacement(SourceRange range, String text) { |
| var builder = createEditBuilder(range.offset, range.length); |
| try { |
| builder.write(text); |
| } finally { |
| _addEditBuilder(builder); |
| } |
| } |
| |
| FileEditBuilderImpl copyWith(ChangeBuilderImpl changeBuilder) { |
| var copy = |
| FileEditBuilderImpl(changeBuilder, fileEdit.file, fileEdit.fileStamp); |
| copy.fileEdit.edits.addAll(fileEdit.edits); |
| return copy; |
| } |
| |
| EditBuilderImpl createEditBuilder(int offset, int length) { |
| return EditBuilderImpl(this, offset, length); |
| } |
| |
| /// Finalize the source file edit that is being built. |
| void finalize() { |
| // Nothing to do. |
| } |
| |
| /// Replace edits in the [range] with the given [edit]. |
| /// The [range] is relative to the original code. |
| void replaceEdits(SourceRange range, SourceEdit edit) { |
| fileEdit.edits.removeWhere((edit) { |
| if (range.contains(edit.offset)) { |
| if (!range.contains(edit.end)) { |
| throw StateError('$edit is not completely in $range'); |
| } |
| return true; |
| } else if (range.contains(edit.end)) { |
| throw StateError('$edit is not completely in $range'); |
| } |
| return false; |
| }); |
| |
| _addEdit(edit); |
| } |
| |
| /// Add the edit from the given [edit] to the edits associated with the |
| /// current file. |
| void _addEdit(SourceEdit edit) { |
| fileEdit.add(edit); |
| var delta = _editDelta(edit); |
| changeBuilder._updatePositions(edit.offset, delta); |
| changeBuilder._lockedPositions.clear(); |
| } |
| |
| /// Add the edit from the given [builder] to the edits associated with the |
| /// current file. |
| void _addEditBuilder(EditBuilderImpl builder) { |
| var edit = builder.sourceEdit; |
| _addEdit(edit); |
| _captureSelection(builder, edit); |
| } |
| |
| /// Capture the selection offset if one was set. |
| void _captureSelection(EditBuilderImpl builder, SourceEdit edit) { |
| var range = builder._selectionRange; |
| if (range != null) { |
| var position = Position(fileEdit.file, range.offset + _deltaToEdit(edit)); |
| changeBuilder.setSelection(position); |
| changeBuilder._setSelectionRange(range); |
| } |
| } |
| |
| /// Return the current delta caused by edits that will be applied before the |
| /// [targetEdit]. In other words, if all of the edits that occur before the |
| /// target edit were to be applied, then the text at the offset of the target |
| /// edit before the applied edits will be at `offset + _deltaToOffset(offset)` |
| /// after the edits. |
| int _deltaToEdit(SourceEdit targetEdit) { |
| var delta = 0; |
| for (var edit in fileEdit.edits) { |
| if (edit.offset < targetEdit.offset) { |
| delta += _editDelta(edit); |
| } |
| } |
| return delta; |
| } |
| |
| /// Return the current delta caused by edits that will be applied before the |
| /// given [offset]. In other words, if all of the edits that have so far been |
| /// added were to be applied, then the text at the given `offset` before the |
| /// applied edits will be at `offset + _deltaToOffset(offset)` after the |
| /// edits. |
| int _deltaToOffset(int offset) { |
| var delta = 0; |
| for (var edit in fileEdit.edits) { |
| if (edit.offset <= offset) { |
| delta += _editDelta(edit); |
| } |
| } |
| return delta; |
| } |
| |
| /// Return the delta introduced by the given `edit`. |
| int _editDelta(SourceEdit edit) => edit.replacement.length - edit.length; |
| } |
| |
| /// A builder used to build a [LinkedEdit] region within an edit. |
| class LinkedEditBuilderImpl implements LinkedEditBuilder { |
| final EditBuilderImpl editBuilder; |
| |
| final List<LinkedEditSuggestion> suggestions = <LinkedEditSuggestion>[]; |
| |
| LinkedEditBuilderImpl(this.editBuilder); |
| |
| @override |
| void addSuggestion(LinkedEditSuggestionKind kind, String value) { |
| suggestions.add(LinkedEditSuggestion(value, kind)); |
| } |
| |
| @override |
| void addSuggestions(LinkedEditSuggestionKind kind, Iterable<String> values) { |
| values.forEach((value) => addSuggestion(kind, value)); |
| } |
| |
| @override |
| void write(String string) { |
| editBuilder.write(string); |
| } |
| |
| @override |
| void writeln([String? string]) { |
| editBuilder.writeln(string); |
| } |
| } |
| |
| /// Workspace that wraps a single [AnalysisSession]. |
| class _SingleSessionWorkspace extends ChangeWorkspace { |
| final AnalysisSession session; |
| |
| _SingleSessionWorkspace(this.session); |
| |
| @override |
| ResourceProvider get resourceProvider => session.resourceProvider; |
| |
| @override |
| bool? containsFile(String path) { |
| var analysisContext = session.analysisContext; |
| return analysisContext.contextRoot.isAnalyzed(path); |
| } |
| |
| @override |
| AnalysisSession? getSession(String path) { |
| if (containsFile(path) ?? false) { |
| return session; |
| } |
| throw StateError('Not in a context root: $path'); |
| } |
| } |