[Analyzer] Add support for LSP experimental SnippetTextEdit
Change-Id: Id37a1954c71fb10c4968c5af6a873ad0ef864a5a Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/191403 Reviewed-by: Brian Wilkerson <brianwilkerson@google.com> Commit-Queue: Brian Wilkerson <brianwilkerson@google.com>
This commit is contained in:
committed by
commit-bot@chromium.org
parent
217cd49aaf
commit
44275c6001
@@ -1573,3 +1573,135 @@ class PublishOutlineParams implements ToJsonable {
|
||||
@override
|
||||
String toString() => jsonEncoder.convert(toJson());
|
||||
}
|
||||
|
||||
class SnippetTextEdit implements TextEdit, ToJsonable {
|
||||
static const jsonHandler =
|
||||
LspJsonHandler(SnippetTextEdit.canParse, SnippetTextEdit.fromJson);
|
||||
|
||||
SnippetTextEdit(
|
||||
{@required this.insertTextFormat,
|
||||
@required this.range,
|
||||
@required this.newText}) {
|
||||
if (insertTextFormat == null) {
|
||||
throw 'insertTextFormat is required but was not provided';
|
||||
}
|
||||
if (range == null) {
|
||||
throw 'range is required but was not provided';
|
||||
}
|
||||
if (newText == null) {
|
||||
throw 'newText is required but was not provided';
|
||||
}
|
||||
}
|
||||
static SnippetTextEdit fromJson(Map<String, dynamic> json) {
|
||||
final insertTextFormat = json['insertTextFormat'] != null
|
||||
? InsertTextFormat.fromJson(json['insertTextFormat'])
|
||||
: null;
|
||||
final range = json['range'] != null ? Range.fromJson(json['range']) : null;
|
||||
final newText = json['newText'];
|
||||
return SnippetTextEdit(
|
||||
insertTextFormat: insertTextFormat, range: range, newText: newText);
|
||||
}
|
||||
|
||||
final InsertTextFormat insertTextFormat;
|
||||
|
||||
/// The string to be inserted. For delete operations use an empty string.
|
||||
final String newText;
|
||||
|
||||
/// The range of the text document to be manipulated. To insert text into a
|
||||
/// document create a range where start === end.
|
||||
final Range range;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
var __result = <String, dynamic>{};
|
||||
__result['insertTextFormat'] = insertTextFormat?.toJson() ??
|
||||
(throw 'insertTextFormat is required but was not set');
|
||||
__result['range'] =
|
||||
range?.toJson() ?? (throw 'range is required but was not set');
|
||||
__result['newText'] =
|
||||
newText ?? (throw 'newText is required but was not set');
|
||||
return __result;
|
||||
}
|
||||
|
||||
static bool canParse(Object obj, LspJsonReporter reporter) {
|
||||
if (obj is Map<String, dynamic>) {
|
||||
reporter.push('insertTextFormat');
|
||||
try {
|
||||
if (!obj.containsKey('insertTextFormat')) {
|
||||
reporter.reportError('must not be undefined');
|
||||
return false;
|
||||
}
|
||||
if (obj['insertTextFormat'] == null) {
|
||||
reporter.reportError('must not be null');
|
||||
return false;
|
||||
}
|
||||
if (!(InsertTextFormat.canParse(obj['insertTextFormat'], reporter))) {
|
||||
reporter.reportError('must be of type InsertTextFormat');
|
||||
return false;
|
||||
}
|
||||
} finally {
|
||||
reporter.pop();
|
||||
}
|
||||
reporter.push('range');
|
||||
try {
|
||||
if (!obj.containsKey('range')) {
|
||||
reporter.reportError('must not be undefined');
|
||||
return false;
|
||||
}
|
||||
if (obj['range'] == null) {
|
||||
reporter.reportError('must not be null');
|
||||
return false;
|
||||
}
|
||||
if (!(Range.canParse(obj['range'], reporter))) {
|
||||
reporter.reportError('must be of type Range');
|
||||
return false;
|
||||
}
|
||||
} finally {
|
||||
reporter.pop();
|
||||
}
|
||||
reporter.push('newText');
|
||||
try {
|
||||
if (!obj.containsKey('newText')) {
|
||||
reporter.reportError('must not be undefined');
|
||||
return false;
|
||||
}
|
||||
if (obj['newText'] == null) {
|
||||
reporter.reportError('must not be null');
|
||||
return false;
|
||||
}
|
||||
if (!(obj['newText'] is String)) {
|
||||
reporter.reportError('must be of type String');
|
||||
return false;
|
||||
}
|
||||
} finally {
|
||||
reporter.pop();
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
reporter.reportError('must be of type SnippetTextEdit');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other is SnippetTextEdit && other.runtimeType == SnippetTextEdit) {
|
||||
return insertTextFormat == other.insertTextFormat &&
|
||||
range == other.range &&
|
||||
newText == other.newText &&
|
||||
true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = 0;
|
||||
hash = JenkinsSmiHash.combine(hash, insertTextFormat.hashCode);
|
||||
hash = JenkinsSmiHash.combine(hash, range.hashCode);
|
||||
hash = JenkinsSmiHash.combine(hash, newText.hashCode);
|
||||
return JenkinsSmiHash.finish(hash);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => jsonEncoder.convert(toJson());
|
||||
}
|
||||
|
||||
@@ -30666,14 +30666,17 @@ class TextDocumentEdit implements ToJsonable {
|
||||
? OptionalVersionedTextDocumentIdentifier.fromJson(json['textDocument'])
|
||||
: null;
|
||||
final edits = json['edits']
|
||||
?.map((item) => TextEdit.canParse(item, nullLspJsonReporter)
|
||||
? Either2<TextEdit, AnnotatedTextEdit>.t1(
|
||||
item != null ? TextEdit.fromJson(item) : null)
|
||||
?.map((item) => SnippetTextEdit.canParse(item, nullLspJsonReporter)
|
||||
? Either3<SnippetTextEdit, AnnotatedTextEdit, TextEdit>.t1(
|
||||
item != null ? SnippetTextEdit.fromJson(item) : null)
|
||||
: (AnnotatedTextEdit.canParse(item, nullLspJsonReporter)
|
||||
? Either2<TextEdit, AnnotatedTextEdit>.t2(
|
||||
? Either3<SnippetTextEdit, AnnotatedTextEdit, TextEdit>.t2(
|
||||
item != null ? AnnotatedTextEdit.fromJson(item) : null)
|
||||
: (throw '''${item} was not one of (TextEdit, AnnotatedTextEdit)''')))
|
||||
?.cast<Either2<TextEdit, AnnotatedTextEdit>>()
|
||||
: (TextEdit.canParse(item, nullLspJsonReporter)
|
||||
? Either3<SnippetTextEdit, AnnotatedTextEdit, TextEdit>.t3(
|
||||
item != null ? TextEdit.fromJson(item) : null)
|
||||
: (throw '''${item} was not one of (SnippetTextEdit, AnnotatedTextEdit, TextEdit)'''))))
|
||||
?.cast<Either3<SnippetTextEdit, AnnotatedTextEdit, TextEdit>>()
|
||||
?.toList();
|
||||
return TextDocumentEdit(textDocument: textDocument, edits: edits);
|
||||
}
|
||||
@@ -30681,7 +30684,7 @@ class TextDocumentEdit implements ToJsonable {
|
||||
/// The edits to be applied.
|
||||
/// @since 3.16.0 - support for AnnotatedTextEdit. This is guarded by the
|
||||
/// client capability `workspace.workspaceEdit.changeAnnotationSupport`
|
||||
final List<Either2<TextEdit, AnnotatedTextEdit>> edits;
|
||||
final List<Either3<SnippetTextEdit, AnnotatedTextEdit, TextEdit>> edits;
|
||||
|
||||
/// The text document to change.
|
||||
final OptionalVersionedTextDocumentIdentifier textDocument;
|
||||
@@ -30726,10 +30729,12 @@ class TextDocumentEdit implements ToJsonable {
|
||||
return false;
|
||||
}
|
||||
if (!((obj['edits'] is List &&
|
||||
(obj['edits'].every((item) => (TextEdit.canParse(item, reporter) ||
|
||||
AnnotatedTextEdit.canParse(item, reporter))))))) {
|
||||
(obj['edits'].every((item) =>
|
||||
(SnippetTextEdit.canParse(item, reporter) ||
|
||||
AnnotatedTextEdit.canParse(item, reporter) ||
|
||||
TextEdit.canParse(item, reporter))))))) {
|
||||
reporter.reportError(
|
||||
'must be of type List<Either2<TextEdit, AnnotatedTextEdit>>');
|
||||
'must be of type List<Either3<SnippetTextEdit, AnnotatedTextEdit, TextEdit>>');
|
||||
return false;
|
||||
}
|
||||
} finally {
|
||||
@@ -30749,8 +30754,9 @@ class TextDocumentEdit implements ToJsonable {
|
||||
listEqual(
|
||||
edits,
|
||||
other.edits,
|
||||
(Either2<TextEdit, AnnotatedTextEdit> a,
|
||||
Either2<TextEdit, AnnotatedTextEdit> b) =>
|
||||
(Either3<SnippetTextEdit, AnnotatedTextEdit, TextEdit> a,
|
||||
Either3<SnippetTextEdit, AnnotatedTextEdit, TextEdit>
|
||||
b) =>
|
||||
a == b) &&
|
||||
true;
|
||||
}
|
||||
@@ -31777,6 +31783,9 @@ class TextEdit implements ToJsonable {
|
||||
if (AnnotatedTextEdit.canParse(json, nullLspJsonReporter)) {
|
||||
return AnnotatedTextEdit.fromJson(json);
|
||||
}
|
||||
if (SnippetTextEdit.canParse(json, nullLspJsonReporter)) {
|
||||
return SnippetTextEdit.fromJson(json);
|
||||
}
|
||||
final range = json['range'] != null ? Range.fromJson(json['range']) : null;
|
||||
final newText = json['newText'];
|
||||
return TextEdit(range: range, newText: newText);
|
||||
|
||||
@@ -78,6 +78,7 @@ class LspClientCapabilities {
|
||||
final Set<SymbolKind> workspaceSymbolKinds;
|
||||
final Set<CompletionItemKind> completionItemKinds;
|
||||
final Set<InsertTextMode> completionInsertTextModes;
|
||||
final bool experimentalSnippetTextEdit;
|
||||
|
||||
LspClientCapabilities(this.raw)
|
||||
: applyEdit = raw?.workspace?.applyEdit ?? false,
|
||||
@@ -125,7 +126,10 @@ class LspClientCapabilities {
|
||||
workDoneProgress = raw.window?.workDoneProgress ?? false,
|
||||
workspaceSymbolKinds = _listToSet(
|
||||
raw?.workspace?.symbol?.symbolKind?.valueSet,
|
||||
defaults: defaultSupportedSymbolKinds);
|
||||
defaults: defaultSupportedSymbolKinds),
|
||||
experimentalSnippetTextEdit =
|
||||
raw.experimental is Map<String, dynamic> &&
|
||||
raw.experimental['snippetTextEdit'] == true;
|
||||
|
||||
static Set<MarkupKind> _completionDocumentationFormats(
|
||||
ClientCapabilities raw) {
|
||||
|
||||
@@ -87,7 +87,7 @@ class PerformRefactorCommandHandler extends SimpleEditCommandHandler {
|
||||
return fileModifiedError;
|
||||
}
|
||||
|
||||
final edit = createWorkspaceEdit(server, change.edits);
|
||||
final edit = createWorkspaceEdit(server, change);
|
||||
return await sendWorkspaceEditToClient(edit);
|
||||
} on InconsistentAnalysisException {
|
||||
return fileModifiedError;
|
||||
|
||||
@@ -143,7 +143,7 @@ class CodeActionHandler extends MessageHandler<CodeActionParams,
|
||||
title: assist.change.message,
|
||||
kind: toCodeActionKind(assist.change.id, CodeActionKind.Refactor),
|
||||
diagnostics: const [],
|
||||
edit: createWorkspaceEdit(server, assist.change.edits),
|
||||
edit: createWorkspaceEdit(server, assist.change),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ class CodeActionHandler extends MessageHandler<CodeActionParams,
|
||||
title: fix.change.message,
|
||||
kind: toCodeActionKind(fix.change.id, CodeActionKind.QuickFix),
|
||||
diagnostics: [diagnostic],
|
||||
edit: createWorkspaceEdit(server, fix.change.edits),
|
||||
edit: createWorkspaceEdit(server, fix.change),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -146,7 +146,8 @@ class CompletionResolveHandler
|
||||
// a command that the client will call to apply those edits later.
|
||||
Command command;
|
||||
if (otherFilesChanges.isNotEmpty) {
|
||||
final workspaceEdit = createWorkspaceEdit(server, otherFilesChanges);
|
||||
final workspaceEdit =
|
||||
createPlainWorkspaceEdit(server, otherFilesChanges);
|
||||
command = Command(
|
||||
title: 'Add import',
|
||||
command: Commands.sendWorkspaceEdit,
|
||||
|
||||
@@ -187,7 +187,7 @@ class RenameHandler extends MessageHandler<RenameParams, WorkspaceEdit> {
|
||||
return fileModifiedError;
|
||||
}
|
||||
|
||||
final workspaceEdit = createWorkspaceEdit(server, change.edits);
|
||||
final workspaceEdit = createWorkspaceEdit(server, change);
|
||||
return success(workspaceEdit);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ class WillRenameFilesHandler extends MessageHandler<RenameFilesParams, void> {
|
||||
}
|
||||
|
||||
final change = await refactoring.createChange();
|
||||
final edit = createWorkspaceEdit(server, change.edits);
|
||||
final edit = createWorkspaceEdit(server, change);
|
||||
|
||||
return success(edit);
|
||||
}
|
||||
|
||||
@@ -108,10 +108,12 @@ String buildSnippetStringWithTabStops(
|
||||
return output.join('');
|
||||
}
|
||||
|
||||
/// Creates a [lsp.WorkspaceEdit] from simple [server.SourceFileEdit]s.
|
||||
///
|
||||
/// Note: This code will fetch the version of each document being modified so
|
||||
/// it's important to call this immediately after computing edits to ensure
|
||||
/// the document is not modified before the version number is read.
|
||||
lsp.WorkspaceEdit createWorkspaceEdit(
|
||||
lsp.WorkspaceEdit createPlainWorkspaceEdit(
|
||||
lsp.LspAnalysisServer server, List<server.SourceFileEdit> edits) {
|
||||
return toWorkspaceEdit(
|
||||
server.clientCapabilities,
|
||||
@@ -126,6 +128,52 @@ lsp.WorkspaceEdit createWorkspaceEdit(
|
||||
.toList());
|
||||
}
|
||||
|
||||
/// Creates a [lsp.WorkspaceEdit] from a [server.SourceChange] that can include
|
||||
/// experimental [server.SnippetTextEdit]s if the client has indicated support
|
||||
/// for these in the experimental section of their client capabilities.
|
||||
///
|
||||
/// Note: This code will fetch the version of each document being modified so
|
||||
/// it's important to call this immediately after computing edits to ensure
|
||||
/// the document is not modified before the version number is read.
|
||||
lsp.WorkspaceEdit createWorkspaceEdit(
|
||||
lsp.LspAnalysisServer server, server.SourceChange change) {
|
||||
// In order to return snippets, we must ensure we are only modifying a single
|
||||
// existing file with a single edit and that there is a linked edit group with
|
||||
// only one position and no suggestions.
|
||||
if (!server.clientCapabilities.experimentalSnippetTextEdit ||
|
||||
change.edits.length != 1 ||
|
||||
change.edits.first.fileStamp == -1 || // new file
|
||||
change.edits.first.edits.length != 1 ||
|
||||
change.linkedEditGroups.isEmpty ||
|
||||
change.linkedEditGroups.first.positions.length != 1 ||
|
||||
change.linkedEditGroups.first.suggestions.isNotEmpty) {
|
||||
return createPlainWorkspaceEdit(server, change.edits);
|
||||
}
|
||||
|
||||
// Additionally, the selection must fall within the edit offset.
|
||||
final edit = change.edits.first.edits.first;
|
||||
final selectionOffset = change.linkedEditGroups.first.positions.first.offset;
|
||||
final selectionLength = change.linkedEditGroups.first.length;
|
||||
|
||||
if (selectionOffset < edit.offset ||
|
||||
selectionOffset + selectionLength > edit.offset + edit.length) {
|
||||
return createPlainWorkspaceEdit(server, change.edits);
|
||||
}
|
||||
|
||||
return toWorkspaceEdit(
|
||||
server.clientCapabilities,
|
||||
change.edits
|
||||
.map((e) => FileEditInformation(
|
||||
server.getVersionedDocumentIdentifier(e.file),
|
||||
server.getLineInfo(e.file),
|
||||
e.edits,
|
||||
selectionOffsetRelative: selectionOffset - edit.offset,
|
||||
selectionLength: selectionLength,
|
||||
newFile: e.fileStamp == -1,
|
||||
))
|
||||
.toList());
|
||||
}
|
||||
|
||||
lsp.CompletionItemKind declarationKindToCompletionItemKind(
|
||||
Set<lsp.CompletionItemKind> supportedCompletionKinds,
|
||||
dec.DeclarationKind kind,
|
||||
@@ -1224,6 +1272,21 @@ lsp.SignatureHelp toSignatureHelp(Set<lsp.MarkupKind> preferredFormats,
|
||||
);
|
||||
}
|
||||
|
||||
lsp.SnippetTextEdit toSnippetTextEdit(
|
||||
LspClientCapabilities capabilities,
|
||||
server.LineInfo lineInfo,
|
||||
server.SourceEdit edit,
|
||||
int selectionOffsetRelative,
|
||||
int selectionLength) {
|
||||
assert(selectionOffsetRelative != null);
|
||||
return lsp.SnippetTextEdit(
|
||||
insertTextFormat: lsp.InsertTextFormat.Snippet,
|
||||
range: toRange(lineInfo, edit.offset, edit.length),
|
||||
newText: buildSnippetStringWithTabStops(
|
||||
edit.replacement, [selectionOffsetRelative, selectionLength ?? 0]),
|
||||
);
|
||||
}
|
||||
|
||||
ErrorOr<server.SourceRange> toSourceRange(
|
||||
server.LineInfo lineInfo, Range range) {
|
||||
if (range == null) {
|
||||
@@ -1247,14 +1310,33 @@ ErrorOr<server.SourceRange> toSourceRange(
|
||||
return success(server.SourceRange(startOffset, endOffset - startOffset));
|
||||
}
|
||||
|
||||
lsp.TextDocumentEdit toTextDocumentEdit(FileEditInformation edit) {
|
||||
lsp.TextDocumentEdit toTextDocumentEdit(
|
||||
LspClientCapabilities capabilities, FileEditInformation edit) {
|
||||
return lsp.TextDocumentEdit(
|
||||
textDocument: edit.doc,
|
||||
edits: edit.edits
|
||||
.map((e) => Either2<lsp.TextEdit, lsp.AnnotatedTextEdit>.t1(
|
||||
toTextEdit(edit.lineInfo, e)))
|
||||
.toList(),
|
||||
);
|
||||
textDocument: edit.doc,
|
||||
edits: edit.edits
|
||||
.map((e) => toTextDocumentEditEdit(capabilities, edit.lineInfo, e,
|
||||
selectionOffsetRelative: edit.selectionOffsetRelative,
|
||||
selectionLength: edit.selectionLength))
|
||||
.toList());
|
||||
}
|
||||
|
||||
Either3<lsp.SnippetTextEdit, lsp.AnnotatedTextEdit, lsp.TextEdit>
|
||||
toTextDocumentEditEdit(
|
||||
LspClientCapabilities capabilities,
|
||||
server.LineInfo lineInfo,
|
||||
server.SourceEdit edit, {
|
||||
int selectionOffsetRelative,
|
||||
int selectionLength,
|
||||
}) {
|
||||
if (!capabilities.experimentalSnippetTextEdit ||
|
||||
selectionOffsetRelative == null) {
|
||||
return Either3<lsp.SnippetTextEdit, lsp.AnnotatedTextEdit, lsp.TextEdit>.t3(
|
||||
toTextEdit(lineInfo, edit));
|
||||
}
|
||||
return Either3<lsp.SnippetTextEdit, lsp.AnnotatedTextEdit, lsp.TextEdit>.t1(
|
||||
toSnippetTextEdit(capabilities, lineInfo, edit, selectionOffsetRelative,
|
||||
selectionLength));
|
||||
}
|
||||
|
||||
lsp.TextEdit toTextEdit(server.LineInfo lineInfo, server.SourceEdit edit) {
|
||||
@@ -1286,7 +1368,7 @@ lsp.WorkspaceEdit toWorkspaceEdit(
|
||||
changes.add(createUnion);
|
||||
}
|
||||
|
||||
final textDocEdit = toTextDocumentEdit(edit);
|
||||
final textDocEdit = toTextDocumentEdit(capabilities, edit);
|
||||
final textDocEditUnion = Either4<lsp.TextDocumentEdit, lsp.CreateFile,
|
||||
lsp.RenameFile, lsp.DeleteFile>.t1(textDocEdit);
|
||||
changes.add(textDocEditUnion);
|
||||
|
||||
@@ -307,6 +307,12 @@ class FileEditInformation {
|
||||
final List<server.SourceEdit> edits;
|
||||
final bool newFile;
|
||||
|
||||
/// The selection offset, relative to the edit.
|
||||
final int selectionOffsetRelative;
|
||||
final int selectionLength;
|
||||
|
||||
FileEditInformation(this.doc, this.lineInfo, this.edits,
|
||||
{this.newFile = false});
|
||||
{this.newFile = false,
|
||||
this.selectionOffsetRelative,
|
||||
this.selectionLength});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
// 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_custom_generated.dart';
|
||||
import 'package:analysis_server/lsp_protocol/protocol_generated.dart';
|
||||
import 'package:analysis_server/lsp_protocol/protocol_special.dart';
|
||||
import 'package:test/test.dart';
|
||||
import 'package:test_reflective_loader/test_reflective_loader.dart';
|
||||
|
||||
@@ -16,6 +18,15 @@ void main() {
|
||||
|
||||
@reflectiveTest
|
||||
class AssistsCodeActionsTest extends AbstractCodeActionsTest {
|
||||
@override
|
||||
void setUp() {
|
||||
super.setUp();
|
||||
writePackageConfig(
|
||||
projectFolderPath,
|
||||
flutter: true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> test_appliesCorrectEdits_withDocumentChangesSupport() async {
|
||||
// This code should get an assist to add a show combinator.
|
||||
const content = '''
|
||||
@@ -107,4 +118,171 @@ class AssistsCodeActionsTest extends AbstractCodeActionsTest {
|
||||
await getCodeActions(pubspecFileUri.toString(), range: startOfDocRange);
|
||||
expect(codeActions, isEmpty);
|
||||
}
|
||||
|
||||
Future<void> test_snippetTextEdits_supported() async {
|
||||
// This tests experimental support for including Snippets in TextEdits.
|
||||
// https://github.com/rust-analyzer/rust-analyzer/blob/b35559a2460e7f0b2b79a7029db0c5d4e0acdb44/docs/dev/lsp-extensions.md#snippet-textedit
|
||||
//
|
||||
// This allows setting the cursor position/selection in TextEdits included
|
||||
// in CodeActions, for example Flutter's "Wrap with widget" assist that
|
||||
// should select the text "widget".
|
||||
|
||||
const content = '''
|
||||
import 'package:flutter/widgets.dart';
|
||||
build() {
|
||||
return Container(
|
||||
child: Row(
|
||||
children: [^
|
||||
Text('111'),
|
||||
Text('222'),
|
||||
Container(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
''';
|
||||
|
||||
// For testing, the snippet will be inserted literally into the text, as
|
||||
// this requires some magic on the client. The expected text should therefore
|
||||
// contain the snippets in the standard format.
|
||||
const expectedContent = r'''
|
||||
import 'package:flutter/widgets.dart';
|
||||
build() {
|
||||
return Container(
|
||||
child: Row(
|
||||
children: [
|
||||
${0:widget}(
|
||||
children: [
|
||||
Text('111'),
|
||||
Text('222'),
|
||||
Container(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
''';
|
||||
|
||||
newFile(mainFilePath, content: withoutMarkers(content));
|
||||
await initialize(
|
||||
textDocumentCapabilities: withCodeActionKinds(
|
||||
emptyTextDocumentClientCapabilities, [CodeActionKind.Refactor]),
|
||||
workspaceCapabilities:
|
||||
withDocumentChangesSupport(emptyWorkspaceClientCapabilities),
|
||||
experimentalCapabilities: {
|
||||
'snippetTextEdit': true,
|
||||
},
|
||||
);
|
||||
|
||||
final marker = positionFromMarker(content);
|
||||
final codeActions = await getCodeActions(mainFileUri.toString(),
|
||||
range: Range(start: marker, end: marker));
|
||||
final assist = findEditAction(codeActions,
|
||||
CodeActionKind('refactor.flutter.wrap.generic'), 'Wrap with widget...');
|
||||
|
||||
// Ensure the edit came back, and using documentChanges.
|
||||
expect(assist, isNotNull);
|
||||
expect(assist.edit.documentChanges, isNotNull);
|
||||
expect(assist.edit.changes, isNull);
|
||||
|
||||
// Ensure applying the changes will give us the expected content.
|
||||
final contents = {
|
||||
mainFilePath: withoutMarkers(content),
|
||||
};
|
||||
applyDocumentChanges(contents, assist.edit.documentChanges);
|
||||
expect(contents[mainFilePath], equals(expectedContent));
|
||||
|
||||
// Also ensure there was a single edit that was correctly marked
|
||||
// as a SnippetTextEdit.
|
||||
final textEdits = _extractTextDocumentEdits(assist.edit.documentChanges)
|
||||
.expand((tde) => tde.edits)
|
||||
.map((edit) => edit.map(
|
||||
(e) => e,
|
||||
(e) => throw 'Expected SnippetTextEdit, got AnnotatedTextEdit',
|
||||
(e) => throw 'Expected SnippetTextEdit, got TextEdit',
|
||||
))
|
||||
.toList();
|
||||
expect(textEdits, hasLength(1));
|
||||
expect(textEdits.first.insertTextFormat, equals(InsertTextFormat.Snippet));
|
||||
}
|
||||
|
||||
Future<void> test_snippetTextEdits_unsupported() async {
|
||||
// This tests experimental support for including Snippets in TextEdits
|
||||
// is not active when the client capabilities do not advertise support for it.
|
||||
// https://github.com/rust-analyzer/rust-analyzer/blob/b35559a2460e7f0b2b79a7029db0c5d4e0acdb44/docs/dev/lsp-extensions.md#snippet-textedit
|
||||
|
||||
const content = '''
|
||||
import 'package:flutter/widgets.dart';
|
||||
build() {
|
||||
return Container(
|
||||
child: Row(
|
||||
children: [^
|
||||
Text('111'),
|
||||
Text('222'),
|
||||
Container(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
''';
|
||||
|
||||
newFile(mainFilePath, content: withoutMarkers(content));
|
||||
await initialize(
|
||||
textDocumentCapabilities: withCodeActionKinds(
|
||||
emptyTextDocumentClientCapabilities, [CodeActionKind.Refactor]),
|
||||
workspaceCapabilities:
|
||||
withDocumentChangesSupport(emptyWorkspaceClientCapabilities),
|
||||
);
|
||||
|
||||
final marker = positionFromMarker(content);
|
||||
final codeActions = await getCodeActions(mainFileUri.toString(),
|
||||
range: Range(start: marker, end: marker));
|
||||
final assist = findEditAction(codeActions,
|
||||
CodeActionKind('refactor.flutter.wrap.generic'), 'Wrap with widget...');
|
||||
|
||||
// Ensure the edit came back, and using documentChanges.
|
||||
expect(assist, isNotNull);
|
||||
expect(assist.edit.documentChanges, isNotNull);
|
||||
expect(assist.edit.changes, isNull);
|
||||
|
||||
// Extract just TextDocumentEdits, create/rename/delete are not relevant.
|
||||
final textDocumentEdits =
|
||||
_extractTextDocumentEdits(assist.edit.documentChanges);
|
||||
final textEdits = textDocumentEdits
|
||||
.expand((tde) => tde.edits)
|
||||
.map((edit) => edit.map((e) => e, (e) => e, (e) => e))
|
||||
.toList();
|
||||
|
||||
// Ensure the edit does _not_ have a format of Snippet, nor does it include
|
||||
// any $ characters that would indicate snippet text.
|
||||
for (final edit in textEdits) {
|
||||
expect(edit, isNot(TypeMatcher<SnippetTextEdit>()));
|
||||
expect(edit.newText, isNot(contains(r'$')));
|
||||
}
|
||||
}
|
||||
|
||||
List<TextDocumentEdit> _extractTextDocumentEdits(
|
||||
Either2<
|
||||
List<TextDocumentEdit>,
|
||||
List<
|
||||
Either4<TextDocumentEdit, CreateFile, RenameFile,
|
||||
DeleteFile>>>
|
||||
documentChanges) =>
|
||||
documentChanges.map(
|
||||
// Already TextDocumentEdits
|
||||
(edits) => edits,
|
||||
// Extract TextDocumentEdits from union of resource changes
|
||||
(changes) => changes
|
||||
.map(
|
||||
(change) => change.map(
|
||||
(textDocEdit) => textDocEdit,
|
||||
(create) => null,
|
||||
(rename) => null,
|
||||
(delete) => null,
|
||||
),
|
||||
)
|
||||
.where((e) => e != null)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -650,10 +650,10 @@ mixin LspAnalysisServerTestMixin implements ClientCapabilitiesHelperMixin {
|
||||
});
|
||||
}
|
||||
|
||||
String applyTextEdit(
|
||||
String content, Either2<TextEdit, AnnotatedTextEdit> change) {
|
||||
String applyTextEdit(String content,
|
||||
Either3<SnippetTextEdit, AnnotatedTextEdit, TextEdit> change) {
|
||||
// Both sites of the union can cast to TextEdit.
|
||||
final edit = change.map((e) => e, (e) => e);
|
||||
final edit = change.map((e) => e, (e) => e, (e) => e);
|
||||
final startPos = edit.range.start;
|
||||
final endPos = edit.range.end;
|
||||
final lineInfo = LineInfo.fromContent(content);
|
||||
@@ -714,8 +714,8 @@ mixin LspAnalysisServerTestMixin implements ClientCapabilitiesHelperMixin {
|
||||
);
|
||||
|
||||
for (final change in sortedChanges) {
|
||||
newContent = applyTextEdit(
|
||||
newContent, Either2<TextEdit, AnnotatedTextEdit>.t1(change));
|
||||
newContent = applyTextEdit(newContent,
|
||||
Either3<SnippetTextEdit, AnnotatedTextEdit, TextEdit>.t3(change));
|
||||
}
|
||||
|
||||
return newContent;
|
||||
@@ -1217,6 +1217,7 @@ mixin LspAnalysisServerTestMixin implements ClientCapabilitiesHelperMixin {
|
||||
TextDocumentClientCapabilities textDocumentCapabilities,
|
||||
ClientCapabilitiesWorkspace workspaceCapabilities,
|
||||
ClientCapabilitiesWindow windowCapabilities,
|
||||
Map<String, Object> experimentalCapabilities,
|
||||
Map<String, Object> initializationOptions,
|
||||
bool throwOnFailure = true,
|
||||
bool allowEmptyRootUri = false,
|
||||
@@ -1225,6 +1226,7 @@ mixin LspAnalysisServerTestMixin implements ClientCapabilitiesHelperMixin {
|
||||
workspace: workspaceCapabilities,
|
||||
textDocument: textDocumentCapabilities,
|
||||
window: windowCapabilities,
|
||||
experimental: experimentalCapabilities,
|
||||
);
|
||||
|
||||
// Handle any standard incoming requests that aren't test-specific, for example
|
||||
|
||||
@@ -272,6 +272,26 @@ List<AstNode> getCustomClasses() {
|
||||
],
|
||||
baseType: 'CompletionItemResolutionInfo',
|
||||
),
|
||||
// Custom types for experimental SnippetTextEdits
|
||||
// https://github.com/rust-analyzer/rust-analyzer/blob/b35559a2460e7f0b2b79a7029db0c5d4e0acdb44/docs/dev/lsp-extensions.md#snippet-textedit
|
||||
interface(
|
||||
'SnippetTextEdit',
|
||||
[
|
||||
field('insertTextFormat', type: 'InsertTextFormat'),
|
||||
],
|
||||
baseType: 'TextEdit',
|
||||
),
|
||||
TypeAlias(
|
||||
null,
|
||||
Token.identifier('TextDocumentEditEdits'),
|
||||
ArrayType(
|
||||
UnionType([
|
||||
Type.identifier('SnippetTextEdit'),
|
||||
Type.identifier('AnnotatedTextEdit'),
|
||||
Type.identifier('TextEdit'),
|
||||
]),
|
||||
),
|
||||
)
|
||||
];
|
||||
return customTypes;
|
||||
}
|
||||
|
||||
@@ -124,6 +124,9 @@ String getImprovedType(String interfaceName, String fieldName) {
|
||||
},
|
||||
'ServerCapabilities': {
|
||||
'changeNotifications': 'bool',
|
||||
},
|
||||
'TextDocumentEdit': {
|
||||
'edits': 'TextDocumentEditEdits',
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user