blob: e25fa3d0412f9070f9e13a2585cf182a83ace241 [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 'package:analysis_server/lsp_protocol/protocol.dart';
import 'package:collection/collection.dart';
import 'package:test/test.dart';
import 'request_helpers_mixin.dart';
/// Applies LSP [WorkspaceEdit]s to produce a flattened string describing the
/// new file contents and any create/rename/deletes to use in test expectations.
class LspChangeVerifier {
/// Marks that signifies the start of an edit description.
static final editMarkerStart = '>>>>>>>>>>';
/// Marks the end of an edit description if the content did not end with a
/// newline.
static final editMarkerEnd = '<<<<<<<<<<';
/// Changes collected while applying the edit.
final _changes = <Uri, _Change>{};
/// A mixin with helpers for applying LSP edits.
final LspVerifyEditHelpersMixin editHelpers;
/// The [WorkspaceEdit] being applied/verified.
final WorkspaceEdit edit;
LspChangeVerifier(this.editHelpers, this.edit) {
_applyEdit();
}
void verifyFiles(String expected, {Map<Uri, int>? expectedVersions}) {
var actual = _toChangeString();
if (actual != expected) {
print('-' * 64);
print(actual.trimRight());
print('-' * 64);
}
expect(actual, equals(expected));
if (expectedVersions != null) {
_verifyDocumentVersions(expectedVersions);
}
}
void _applyChanges(Map<Uri, List<TextEdit>> changes) {
changes.forEach((fileUri, edits) {
var change = _change(fileUri);
change.content = _applyTextEdits(change.content!, edits);
// Record annotations with their ranges.
for (var edit in edits.whereType<AnnotatedTextEdit>()) {
var annotation = this.edit.changeAnnotations![edit.annotationId]!;
change.annotations
.putIfAbsent(annotation, () => [])
.add(edit.range.toDisplayString());
}
});
}
void _applyDocumentChanges(DocumentChanges documentChanges) {
_applyResourceChanges(documentChanges);
}
void _applyEdit() {
var documentChanges = edit.documentChanges;
var changes = edit.changes;
if (documentChanges != null) {
_applyDocumentChanges(documentChanges);
}
if (changes != null) {
_applyChanges(changes);
}
}
void _applyResourceChanges(DocumentChanges changes) {
for (var change in changes) {
change.map(
_applyResourceCreate,
_applyResourceDelete,
_applyResourceRename,
_applyTextDocumentEdit,
);
}
}
void _applyResourceCreate(CreateFile create) {
var uri = create.uri;
var change = _change(uri);
if (change.content != null) {
throw 'Received create instruction for $uri which already exists';
}
_change(uri).content = '';
change.actions.add('created');
if (create.annotationId case String annotationId) {
var annotation = edit.changeAnnotations![annotationId]!;
change.annotations.putIfAbsent(annotation, () => []).add('create');
}
}
void _applyResourceDelete(DeleteFile delete) {
var uri = delete.uri;
var change = _change(uri);
if (change.content == null) {
throw 'Received delete instruction for $uri which does not exist';
}
change.content = null;
change.actions.add('deleted');
if (delete.annotationId case String annotationId) {
var annotation = edit.changeAnnotations![annotationId]!;
change.annotations.putIfAbsent(annotation, () => []).add('delete');
}
}
void _applyResourceRename(RenameFile rename) {
var oldUri = rename.oldUri;
var newUri = rename.newUri;
var oldChange = _change(oldUri);
var newChange = _change(newUri);
if (oldChange.content == null) {
throw 'Received rename instruction from $oldUri which did not exist';
} else if (newChange.content != null) {
throw 'Received rename instruction to $newUri which already exists';
}
newChange.content = oldChange.content;
newChange.actions.add('renamed from ${_relativeUri(oldUri)}');
oldChange.content = null;
oldChange.actions.add('renamed to ${_relativeUri(newUri)}');
if (rename.annotationId case String annotationId) {
var annotation = edit.changeAnnotations![annotationId]!;
newChange.annotations.putIfAbsent(annotation, () => []).add('rename');
oldChange.annotations.putIfAbsent(annotation, () => []).add('rename');
}
}
void _applyTextDocumentEdit(TextDocumentEdit documentEdit) {
var uri = documentEdit.textDocument.uri;
var change = _change(uri);
// Compute new content from the edits.
if (change.content == null) {
throw 'Received edits for $uri which does not exist. '
'Perhaps a CreateFile change was missing from the edits?';
}
change.content = _applyTextDocumentEditEdit(change.content!, documentEdit);
// Record annotations with their ranges.
for (var editEither in documentEdit.edits) {
editEither.map(
(annotated) {
var annotation = edit.changeAnnotations![annotated.annotationId]!;
change.annotations
.putIfAbsent(annotation, () => [])
.add(annotated.range.toDisplayString());
},
// No annotations on these other kinds.
(snippet) {},
(textEdit) {},
);
}
}
String _applyTextDocumentEditEdit(String content, TextDocumentEdit edit) {
// To simulate the behaviour we'll get from an LSP client, apply edits from
// the latest offset to the earliest, but with items at the same offset
// being reversed so that when applied sequentially they appear in the
// document in-order.
//
// This is essentially a stable sort over the offset (descending), but since
// List.sort() is not stable so we additionally sort by index).
var indexedEdits =
edit.edits.mapIndexed(TextEditWithIndex.fromUnion).toList();
indexedEdits.sort(TextEditWithIndex.compare);
return indexedEdits
.map((e) => e.edit)
.fold(content, editHelpers.applyTextEdit);
}
String _applyTextEdits(String content, List<TextEdit> changes) =>
editHelpers.applyTextEdits(content, changes);
_Change _change(Uri fileUri) => _changes.putIfAbsent(
fileUri, () => _Change(_getCurrentFileContent(fileUri)));
void _expectDocumentVersion(
TextDocumentEdit edit,
Map<Uri, int> expectedVersions,
) {
var uri = edit.textDocument.uri;
var expectedVersion = expectedVersions[uri];
expect(edit.textDocument.version, equals(expectedVersion));
}
String? _getCurrentFileContent(Uri uri) =>
editHelpers.getCurrentFileContent(uri);
String _relativeUri(Uri uri) => editHelpers.relativeUri(uri);
String _toChangeString() {
var buffer = StringBuffer();
for (var MapEntry(key: uri, value: change)
in _changes.entries.sortedBy((entry) => _relativeUri(entry.key))) {
// Write the path in a common format for Windows/non-Windows.
var relativePath = _relativeUri(uri);
var content = change.content;
var annotations = change.annotations;
// Write header/actions.
buffer.write('$editMarkerStart $relativePath');
for (var action in change.actions) {
buffer.write(' $action');
}
if (content?.isEmpty ?? false) {
buffer.write(' empty');
}
buffer.writeln();
// Write any annotations.
if (annotations.isNotEmpty) {
for (var MapEntry(key: annotation, value: operations)
in annotations.entries) {
buffer.write('$editMarkerStart ${annotation.label}');
if (annotation.description != null) {
buffer.write(' (${annotation.description})');
}
buffer.write(': ${operations.join(', ')}');
buffer.writeln();
}
}
// Write content.
if (content != null) {
buffer.write(content);
// If the content didn't end with a newline we need to add one, but
// add a marked so it's clear there was no trailing newline.
if (content.isNotEmpty && !content.endsWith('\n')) {
buffer.writeln(editMarkerEnd);
}
}
}
return buffer.toString();
}
/// Validates the document versions for a set of edits match the versions in
/// the supplied map.
void _verifyDocumentVersions(Map<Uri, int> expectedVersions) {
// For resource changes, we only need to validate changes since
// creates/renames/deletes do not supply versions.
for (var change in edit.documentChanges!) {
change.map(
(create) {},
(delete) {},
(rename) {},
(edit) => _expectDocumentVersion(edit, expectedVersions),
);
}
}
}
/// An LSP TextEdit with its index, and a comparer to sort them in a way that
/// can be applied sequentially while preserving expected behaviour.
class TextEditWithIndex {
final int index;
final TextEdit edit;
TextEditWithIndex(this.index, this.edit);
TextEditWithIndex.fromUnion(
this.index, Either3<AnnotatedTextEdit, SnippetTextEdit, TextEdit> edit)
: edit = edit.map((e) => e, (e) => e, (e) => e);
/// Compares two [TextEditWithIndex] to sort them by the order in which they
/// can be sequentially applied to a String to match the behaviour of an LSP
/// client.
static int compare(TextEditWithIndex edit1, TextEditWithIndex edit2) {
var end1 = edit1.edit.range.end;
var end2 = edit2.edit.range.end;
// VS Code's implementation of this is here:
// https://github.com/microsoft/vscode/blob/856a306d1a9b0879727421daf21a8059e671e3ea/src/vs/editor/common/model/pieceTreeTextBuffer/pieceTreeTextBuffer.ts#L475
if (end1.line != end2.line) {
return end1.line.compareTo(end2.line) * -1;
} else if (end1.character != end2.character) {
return end1.character.compareTo(end2.character) * -1;
} else {
return edit1.index.compareTo(edit2.index) * -1;
}
}
}
class _Change {
String? content;
final actions = <String>[];
final annotations = <ChangeAnnotation, List<String>>{};
_Change(this.content);
}
extension on Range {
String toDisplayString() => start.line == end.line
? 'line ${start.line + 1}'
: 'lines ${start.line + 1}-${end.line + 1}';
}