[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);
     });