[analysis_server] Support multi-group snippets in LSP CodeActions
Fixes https://github.com/Dart-Code/Dart-Code/issues/3869.
Change-Id: Ib1925cc090fe62815c9257c72823c0e84d93efd0
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/236640
Reviewed-by: Brian Wilkerson <brianwilkerson@google.com>
Commit-Queue: Brian Wilkerson <brianwilkerson@google.com>
diff --git a/pkg/analysis_server/lib/src/lsp/handlers/handler_code_actions.dart b/pkg/analysis_server/lib/src/lsp/handlers/handler_code_actions.dart
index a1e8437..6ae02e1 100644
--- a/pkg/analysis_server/lib/src/lsp/handlers/handler_code_actions.dart
+++ b/pkg/analysis_server/lib/src/lsp/handlers/handler_code_actions.dart
@@ -169,12 +169,13 @@
/// 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.
- CodeAction _createAssistAction(SourceChange change) {
+ CodeAction _createAssistAction(SourceChange change, ResolvedUnitResult unit) {
return CodeAction(
title: change.message,
kind: toCodeActionKind(change.id, CodeActionKind.Refactor),
diagnostics: const [],
- edit: createWorkspaceEdit(server, change),
+ edit: createWorkspaceEdit(server, change,
+ allowSnippets: true, filePath: unit.path, lineInfo: unit.lineInfo),
);
}
@@ -182,12 +183,14 @@
/// 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.
- CodeAction _createFixAction(SourceChange change, Diagnostic diagnostic) {
+ CodeAction _createFixAction(
+ SourceChange change, Diagnostic diagnostic, ResolvedUnitResult unit) {
return CodeAction(
title: change.message,
kind: toCodeActionKind(change.id, CodeActionKind.QuickFix),
diagnostics: [diagnostic],
- edit: createWorkspaceEdit(server, change),
+ edit: createWorkspaceEdit(server, change,
+ allowSnippets: true, filePath: unit.path, lineInfo: unit.lineInfo),
);
}
@@ -269,12 +272,12 @@
final codeActions = <CodeAction>[];
codeActions.addAll(assists.map((assist) {
- final action = _createAssistAction(assist.change);
+ final action = _createAssistAction(assist.change, unit);
codeActionPriorities[action] = assist.kind.priority;
return action;
}));
codeActions.addAll(pluginChanges.map((change) {
- final action = _createAssistAction(change.change);
+ final action = _createAssistAction(change.change, unit);
codeActionPriorities[action] = change.priority;
return action;
}));
@@ -361,7 +364,7 @@
);
codeActions.addAll(
fixes.map((fix) {
- final action = _createFixAction(fix.change, diagnostic);
+ final action = _createFixAction(fix.change, diagnostic, unit);
codeActionPriorities[action] = fix.kind.priority;
return action;
}),
@@ -382,7 +385,7 @@
final pluginFixActions = pluginFixes.expand(
(fix) => fix.fixes.map((fixChange) {
final action = _createFixAction(
- fixChange.change, pluginErrorToDiagnostic(fix.error));
+ fixChange.change, pluginErrorToDiagnostic(fix.error), unit);
codeActionPriorities[action] = fixChange.priority;
return action;
}),
diff --git a/pkg/analysis_server/lib/src/lsp/mapping.dart b/pkg/analysis_server/lib/src/lsp/mapping.dart
index 9026e05..8125d9a 100644
--- a/pkg/analysis_server/lib/src/lsp/mapping.dart
+++ b/pkg/analysis_server/lib/src/lsp/mapping.dart
@@ -119,51 +119,74 @@
return edit;
}
-/// 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.
+/// Creates a [lsp.WorkspaceEdit] from a [server.SourceChange].
+///
+/// Can return experimental [server.SnippetTextEdit]s if the following are true:
+/// - the client has indicated support for in the experimental section of their
+/// client capabilities, and
+/// - [allowSnippets] is true, and
+/// - [change] contains only a single edit to the single file [filePath]
+/// - [lineInfo] is provided (which should be for the single edited file)
///
/// 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) {
+ lsp.LspAnalysisServer server,
+ server.SourceChange change, {
+ // The caller must specify whether snippets are valid here for where they're
+ // sending this edit. Right now, support is limited to CodeActions.
+ bool allowSnippets = false,
+ String? filePath,
+ LineInfo? lineInfo,
+}) {
// 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 ||
+ // existing file with a single edit and that there is either a selection or a
+ // linked edit group (otherwise there's no value in snippets).
+ if (!allowSnippets ||
+ !server.clientCapabilities!.experimentalSnippetTextEdit ||
+ !server.clientCapabilities!.documentChanges ||
+ filePath == null ||
+ lineInfo == null ||
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) {
+ change.edits.single.fileStamp == -1 || // new file
+ change.edits.single.file != filePath ||
+ change.edits.single.edits.length != 1 ||
+ (change.selection == null && change.linkedEditGroups.isEmpty)) {
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;
+ final fileEdit = change.edits.single;
+ final snippetEdits = toSnippetTextEdits(
+ fileEdit.file,
+ fileEdit,
+ change.linkedEditGroups,
+ lineInfo,
+ selectionOffset: change.selection?.offset,
+ );
- if (selectionOffset < edit.offset ||
- selectionOffset + selectionLength > edit.offset + edit.length) {
- return createPlainWorkspaceEdit(server, change.edits);
- }
+ // Compile the edits into a TextDocumentEdit for this file.
+ final textDocumentEdit = lsp.TextDocumentEdit(
+ textDocument: server.getVersionedDocumentIdentifier(fileEdit.file),
+ edits: snippetEdits
+ .map((e) => Either3<lsp.SnippetTextEdit, lsp.AnnotatedTextEdit,
+ lsp.TextEdit>.t1(e))
+ .toList(),
+ );
- return toWorkspaceEdit(
- server.clientCapabilities!,
- change.edits
- .map((e) => FileEditInformation(
- server.getVersionedDocumentIdentifier(e.file),
- // We should never produce edits for a file with no LineInfo.
- server.getLineInfo(e.file)!,
- e.edits,
- selectionOffsetRelative: selectionOffset - edit.offset,
- selectionLength: selectionLength,
- newFile: e.fileStamp == -1,
- ))
- .toList());
+ // Convert to the union that documentChanges require.
+ final textDocumentEditsAsUnion = Either4<lsp.TextDocumentEdit, lsp.CreateFile,
+ lsp.RenameFile, lsp.DeleteFile>.t1(textDocumentEdit);
+
+ // Convert to the union that documentChanges is.
+ final documentChanges = Either2<
+ List<lsp.TextDocumentEdit>,
+ List<
+ Either4<lsp.TextDocumentEdit, lsp.CreateFile, lsp.RenameFile,
+ lsp.DeleteFile>>>.t2([textDocumentEditsAsUnion]);
+
+ /// Add the textDocumentEdit to a WorkspaceEdit.
+ return lsp.WorkspaceEdit(documentChanges: documentChanges);
}
lsp.CompletionItemKind? declarationKindToCompletionItemKind(
diff --git a/pkg/analysis_server/lib/src/lsp/snippets.dart b/pkg/analysis_server/lib/src/lsp/snippets.dart
index e88dcaa..0c9d5c6 100644
--- a/pkg/analysis_server/lib/src/lsp/snippets.dart
+++ b/pkg/analysis_server/lib/src/lsp/snippets.dart
@@ -85,6 +85,11 @@
// Use the index as an ID to keep all related positions together (so
// the remain "linked").
linkedGroupId: index,
+ // If there is no selection, no tabstops, and only a single edit group
+ // allow it to be the final tabstop.
+ isFinal: selectionOffset == null &&
+ (tabStopOffsetLengthPairs?.isEmpty ?? false) &&
+ editGroups?.length == 1,
),
);
}
diff --git a/pkg/analysis_server/test/lsp/code_actions_assists_test.dart b/pkg/analysis_server/test/lsp/code_actions_assists_test.dart
index cf67637f..68652df 100644
--- a/pkg/analysis_server/test/lsp/code_actions_assists_test.dart
+++ b/pkg/analysis_server/test/lsp/code_actions_assists_test.dart
@@ -249,7 +249,70 @@
);
}
- Future<void> test_snippetTextEdits_supported() async {
+ Future<void> test_snippetTextEdits_multiEditGroup() async {
+ // As test_snippetTextEdits_singleEditGroup, but uses an assist that
+ // produces multiple linked edit groups.
+
+ const content = '''
+ import 'package:flutter/widgets.dart';
+ build() {
+ return Container(
+ child: Ro^w(
+ children: [
+ Text('111'),
+ Text('222'),
+ Container(),
+ ],
+ ),
+ );
+ }
+ ''';
+
+ const expectedContent = r'''
+ import 'package:flutter/widgets.dart';
+ build() {
+ return Container(
+ child: ${1:widget}(
+ ${2:child}: Row(
+ 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 codeActions = await getCodeActions(mainFileUri.toString(),
+ position: positionFromMarker(content));
+ final assist = findEditAction(
+ codeActions,
+ CodeActionKind('refactor.flutter.wrap.generic'),
+ 'Wrap with widget...')!;
+
+ // Ensure applying the changes will give us the expected content.
+ final edit = assist.edit!;
+ final contents = {
+ mainFilePath: withoutMarkers(content),
+ };
+ applyDocumentChanges(contents, edit.documentChanges!);
+ expect(contents[mainFilePath], equals(expectedContent));
+ }
+
+ Future<void> test_snippetTextEdits_singleEditGroup() 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
//
@@ -273,8 +336,8 @@
''';
// 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.
+ // 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() {
@@ -426,6 +489,63 @@
);
}
+ Future<void> test_surround_editGroupsAndSelection() async {
+ const content = '''
+void f() {
+ [[print(0);]]
+}
+''';
+
+ const expectedContent = r'''
+void f() {
+ if (${1:condition}) {
+ print(0);
+ }$0
+}
+''';
+
+ newFile(mainFilePath, content: withoutMarkers(content));
+ await initialize(
+ textDocumentCapabilities: withCodeActionKinds(
+ emptyTextDocumentClientCapabilities, [CodeActionKind.Refactor]),
+ workspaceCapabilities:
+ withDocumentChangesSupport(emptyWorkspaceClientCapabilities),
+ experimentalCapabilities: {
+ 'snippetTextEdit': true,
+ },
+ );
+
+ final codeActions = await getCodeActions(mainFileUri.toString(),
+ range: rangeFromMarkers(content));
+ final assist = findEditAction(codeActions,
+ CodeActionKind('refactor.surround.if'), "Surround with 'if'")!;
+
+ // Ensure the edit came back, and using documentChanges.
+ final edit = assist.edit!;
+ expect(edit.documentChanges, isNotNull);
+ expect(edit.changes, isNull);
+
+ // Ensure applying the changes will give us the expected content.
+ final contents = {
+ mainFilePath: withoutMarkers(content),
+ };
+ applyDocumentChanges(contents, edit.documentChanges!);
+ expect(contents[mainFilePath], equals(expectedContent));
+
+ // Also ensure there was a single edit that was correctly marked
+ // as a SnippetTextEdit.
+ final textEdits = _extractTextDocumentEdits(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));
+ }
+
List<TextDocumentEdit> _extractTextDocumentEdits(
Either2<
List<TextDocumentEdit>,
diff --git a/pkg/analysis_server/test/lsp/snippets_test.dart b/pkg/analysis_server/test/lsp/snippets_test.dart
index a126c9b..48cefb0 100644
--- a/pkg/analysis_server/test/lsp/snippets_test.dart
+++ b/pkg/analysis_server/test/lsp/snippets_test.dart
@@ -155,7 +155,7 @@
],
);
expect(result, equals(r'''
-var ${1|a,aaa,bbb\${\}\,\|,ccc|} = 1;
+var ${0|a,aaa,bbb\${\}\,\|,ccc|} = 1;
'''));
}
@@ -180,8 +180,8 @@
],
);
expect(result, equals(r'''
-class $1 {
- $1();
+class $0 {
+ $0();
}
'''));
}
@@ -208,8 +208,8 @@
],
);
expect(result, equals(r'''
-class ${1:A} {
- ${1:A}();
+class ${0:A} {
+ ${0:A}();
}
'''));
}
@@ -235,8 +235,8 @@
],
);
expect(result, equals(r'''
-class ${1:A} {
- ${1:A}();
+class ${0:A} {
+ ${0:A}();
}
'''));
}
@@ -264,8 +264,8 @@
],
);
expect(result, equals(r'''
-class ${1:A} {
- ${1:A}();
+class ${0:A} {
+ ${0:A}();
}
'''));
}