[analyzer] Handle creating new files from code actions
Fixes https://github.com/Dart-Code/Dart-Code/issues/3141.
Change-Id: I44d7f20962512988ff1d54c7b38b6a144f973a58
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/185140
Reviewed-by: Brian Wilkerson <brianwilkerson@google.com>
Commit-Queue: Brian Wilkerson <brianwilkerson@google.com>
diff --git a/pkg/analysis_server/lib/src/lsp/mapping.dart b/pkg/analysis_server/lib/src/lsp/mapping.dart
index 02af79d..87231e2 100644
--- a/pkg/analysis_server/lib/src/lsp/mapping.dart
+++ b/pkg/analysis_server/lib/src/lsp/mapping.dart
@@ -116,9 +116,12 @@
server.clientCapabilities?.workspace,
edits
.map((e) => FileEditInformation(
- server.getVersionedDocumentIdentifier(e.file),
- server.getLineInfo(e.file),
- e.edits))
+ server.getVersionedDocumentIdentifier(e.file),
+ server.getLineInfo(e.file),
+ e.edits,
+ // fileStamp == 1 is used by the server to indicate the file needs creating.
+ newFile: e.fileStamp == -1,
+ ))
.toList());
}
@@ -1253,14 +1256,36 @@
final clientSupportsTextDocumentEdits =
capabilities?.workspaceEdit?.documentChanges == true;
if (clientSupportsTextDocumentEdits) {
+ final clientSupportsCreate = capabilities?.workspaceEdit?.resourceOperations
+ ?.contains(ResourceOperationKind.Create) ??
+ false;
+ final changes = <
+ Either4<lsp.TextDocumentEdit, lsp.CreateFile, lsp.RenameFile,
+ lsp.DeleteFile>>[];
+
+ // Convert each SourceEdit to either a TextDocumentEdit or a
+ // CreateFile + a TextDocumentEdit depending on whether it's a new
+ // file.
+ for (final edit in edits) {
+ if (clientSupportsCreate && edit.newFile) {
+ final create = lsp.CreateFile(uri: edit.doc.uri);
+ final createUnion = Either4<lsp.TextDocumentEdit, lsp.CreateFile,
+ lsp.RenameFile, lsp.DeleteFile>.t2(create);
+ changes.add(createUnion);
+ }
+
+ final textDocEdit = toTextDocumentEdit(edit);
+ final textDocEditUnion = Either4<lsp.TextDocumentEdit, lsp.CreateFile,
+ lsp.RenameFile, lsp.DeleteFile>.t1(textDocEdit);
+ changes.add(textDocEditUnion);
+ }
+
return lsp.WorkspaceEdit(
documentChanges: Either2<
List<lsp.TextDocumentEdit>,
List<
Either4<lsp.TextDocumentEdit, lsp.CreateFile, lsp.RenameFile,
- lsp.DeleteFile>>>.t1(
- edits.map(toTextDocumentEdit).toList(),
- ));
+ lsp.DeleteFile>>>.t2(changes));
} else {
return lsp.WorkspaceEdit(changes: toWorkspaceEditChanges(edits));
}
diff --git a/pkg/analysis_server/lib/src/lsp/source_edits.dart b/pkg/analysis_server/lib/src/lsp/source_edits.dart
index 0307417..e1cc013 100644
--- a/pkg/analysis_server/lib/src/lsp/source_edits.dart
+++ b/pkg/analysis_server/lib/src/lsp/source_edits.dart
@@ -305,6 +305,8 @@
final OptionalVersionedTextDocumentIdentifier doc;
final LineInfo lineInfo;
final List<server.SourceEdit> edits;
+ final bool newFile;
- FileEditInformation(this.doc, this.lineInfo, this.edits);
+ FileEditInformation(this.doc, this.lineInfo, this.edits,
+ {this.newFile = false});
}
diff --git a/pkg/analysis_server/test/lsp/code_actions_fixes_test.dart b/pkg/analysis_server/test/lsp/code_actions_fixes_test.dart
index a0c3f5b..cdac70a 100644
--- a/pkg/analysis_server/test/lsp/code_actions_fixes_test.dart
+++ b/pkg/analysis_server/test/lsp/code_actions_fixes_test.dart
@@ -4,6 +4,7 @@
import 'package:analysis_server/lsp_protocol/protocol_generated.dart';
import 'package:linter/src/rules.dart';
+import 'package:path/path.dart' as path;
import 'package:test/test.dart';
import 'package:test_reflective_loader/test_reflective_loader.dart';
@@ -96,6 +97,38 @@
expect(contents[mainFilePath], equals(expectedContent));
}
+ Future<void> test_createFile() async {
+ const content = '''
+ import '[[newfile.dart]]';
+ ''';
+
+ final expectedCreatedFile =
+ path.join(path.dirname(mainFilePath), 'newfile.dart');
+
+ newFile(mainFilePath, content: withoutMarkers(content));
+ await initialize(
+ textDocumentCapabilities: withCodeActionKinds(
+ emptyTextDocumentClientCapabilities, [CodeActionKind.QuickFix]),
+ workspaceCapabilities: withResourceOperationKinds(
+ emptyWorkspaceClientCapabilities, [ResourceOperationKind.Create]),
+ );
+
+ final codeActions = await getCodeActions(mainFileUri.toString(),
+ range: rangeFromMarkers(content));
+ final fixAction = findEditAction(codeActions,
+ CodeActionKind('quickfix.create.file'), "Create file 'newfile.dart'");
+
+ expect(fixAction, isNotNull);
+ expect(fixAction.edit.documentChanges, isNotNull);
+
+ // Ensure applying the changes creates the file and with the expected content.
+ final contents = {
+ mainFilePath: withoutMarkers(content),
+ };
+ applyDocumentChanges(contents, fixAction.edit.documentChanges);
+ expect(contents[expectedCreatedFile], isNotEmpty);
+ }
+
Future<void> test_filtersCorrectly() async {
const content = '''
import 'dart:async';
diff --git a/pkg/analysis_server/test/lsp/server_abstract.dart b/pkg/analysis_server/test/lsp/server_abstract.dart
index ce6fb8b..f9d072c 100644
--- a/pkg/analysis_server/test/lsp/server_abstract.dart
+++ b/pkg/analysis_server/test/lsp/server_abstract.dart
@@ -403,6 +403,19 @@
});
}
+ ClientCapabilitiesWorkspace withResourceOperationKinds(
+ ClientCapabilitiesWorkspace source,
+ List<ResourceOperationKind> kinds,
+ ) {
+ return extendWorkspaceCapabilities(source, {
+ 'workspaceEdit': {
+ 'documentChanges':
+ true, // docChanges aren't included in resourceOperations
+ 'resourceOperations': kinds.map((k) => k.toJson()).toList(),
+ }
+ });
+ }
+
TextDocumentClientCapabilities withSignatureHelpContentFormat(
TextDocumentClientCapabilities source,
List<MarkupKind> formats,
@@ -572,8 +585,23 @@
Map<String, String> oldFileContent,
List<Either4<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>> changes,
) {
- // TODO(dantup): Implement handling of resource changes (not currently used).
- throw 'Test helper applyResourceChanges not currently supported';
+ for (final change in changes) {
+ change.map(
+ (textDocEdit) => applyTextDocumentEdits(oldFileContent, [textDocEdit]),
+ (create) => applyResourceCreate(oldFileContent, create),
+ (rename) => throw 'applyResourceChanges:Delete not currently supported',
+ (delete) => throw 'applyResourceChanges:Delete not currently supported',
+ );
+ }
+ }
+
+ void applyResourceCreate(
+ Map<String, String> oldFileContent, CreateFile create) {
+ final path = Uri.parse(create.uri).toFilePath();
+ if (oldFileContent.containsKey(path)) {
+ throw 'Recieved create instruction for $path which already existed.';
+ }
+ oldFileContent[path] = '';
}
String applyTextDocumentEdit(String content, TextDocumentEdit edit) {
@@ -585,7 +613,8 @@
edits.forEach((edit) {
final path = Uri.parse(edit.textDocument.uri).toFilePath();
if (!oldFileContent.containsKey(path)) {
- throw 'Recieved edits for $path which was not provided as a file to be edited';
+ throw 'Recieved edits for $path which was not provided as a file to be edited. '
+ 'Perhaps a CreateFile change was missing from the edits?';
}
oldFileContent[path] = applyTextDocumentEdit(oldFileContent[path], edit);
});