DAS plugins: support overlay changes

This adds and tests support for adding file overlays, changing them, and
removing them.

Work towards https://github.com/dart-lang/sdk/issues/53402

Change-Id: I7939f8199639f7e336cd97515c2f7d48b1d777a0
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/384305
Reviewed-by: Brian Wilkerson <brianwilkerson@google.com>
Reviewed-by: Konstantin Shcheglov <scheglov@google.com>
Commit-Queue: Samuel Rawlins <srawlins@google.com>
diff --git a/pkg/analysis_server_plugin/lib/src/plugin_server.dart b/pkg/analysis_server_plugin/lib/src/plugin_server.dart
index 9df1ebb..045be72 100644
--- a/pkg/analysis_server_plugin/lib/src/plugin_server.dart
+++ b/pkg/analysis_server_plugin/lib/src/plugin_server.dart
@@ -69,6 +69,9 @@
   /// The recent state of analysis reults, to be cleared on file changes.
   final _recentState = <String, _PluginState>{};
 
+  /// The next modification stamp for a changed file in the [resourceProvider].
+  int _overlayModificationStamp = 0;
+
   PluginServer({
     required ResourceProvider resourceProvider,
     required List<Plugin> plugins,
@@ -79,29 +82,6 @@
     }
   }
 
-  /// Handles an 'analysis.setContextRoots' request.
-  Future<protocol.AnalysisSetContextRootsResult> handleAnalysisSetContextRoots(
-      protocol.AnalysisSetContextRootsParams parameters) async {
-    var currentContextCollection = _contextCollection;
-    if (currentContextCollection != null) {
-      _contextCollection = null;
-      await currentContextCollection.dispose();
-    }
-
-    var includedPaths = parameters.roots.map((e) => e.root).toList();
-    var contextCollection = AnalysisContextCollectionImpl(
-      resourceProvider: _resourceProvider,
-      includedPaths: includedPaths,
-      byteStore: _byteStore,
-      sdkPath: _sdkPath,
-      fileContentCache: FileContentCache(_resourceProvider),
-    );
-    _contextCollection = contextCollection;
-    await _analyzeAllFilesInContextCollection(
-        contextCollection: contextCollection);
-    return protocol.AnalysisSetContextRootsResult();
-  }
-
   /// Handles an 'edit.getFixes' request.
   ///
   /// Throws a [RequestFailure] if the request could not be handled.
@@ -193,8 +173,6 @@
 
   /// This method is invoked when a new instance of [AnalysisContextCollection]
   /// is created, so the plugin can perform initial analysis of analyzed files.
-  ///
-  /// By default analyzes every [AnalysisContext] with [_analyzeFiles].
   Future<void> _analyzeAllFilesInContextCollection({
     required AnalysisContextCollection contextCollection,
   }) async {
@@ -326,27 +304,36 @@
       case protocol.ANALYSIS_REQUEST_GET_NAVIGATION:
       case protocol.ANALYSIS_REQUEST_HANDLE_WATCH_EVENTS:
         result = null;
+
       case protocol.ANALYSIS_REQUEST_SET_CONTEXT_ROOTS:
         var params =
             protocol.AnalysisSetContextRootsParams.fromRequest(request);
-        result = await handleAnalysisSetContextRoots(params);
+        result = await _handleAnalysisSetContextRoots(params);
+
       case protocol.ANALYSIS_REQUEST_SET_PRIORITY_FILES:
       case protocol.ANALYSIS_REQUEST_SET_SUBSCRIPTIONS:
       case protocol.ANALYSIS_REQUEST_UPDATE_CONTENT:
+        var params = protocol.AnalysisUpdateContentParams.fromRequest(request);
+        result = await _handleAnalysisUpdateContent(params);
+
       case protocol.COMPLETION_REQUEST_GET_SUGGESTIONS:
       case protocol.EDIT_REQUEST_GET_ASSISTS:
       case protocol.EDIT_REQUEST_GET_AVAILABLE_REFACTORINGS:
         result = null;
+
       case protocol.EDIT_REQUEST_GET_FIXES:
         var params = protocol.EditGetFixesParams.fromRequest(request);
         result = await handleEditGetFixes(params);
+
       case protocol.EDIT_REQUEST_GET_REFACTORING:
         result = null;
+
       case protocol.PLUGIN_REQUEST_SHUTDOWN:
         _channel.sendResponse(protocol.PluginShutdownResult()
             .toResponse(request.id, requestTime));
         _channel.close();
         return null;
+
       case protocol.PLUGIN_REQUEST_VERSION_CHECK:
         var params = protocol.PluginVersionCheckParams.fromRequest(request);
         result = await handlePluginVersionCheck(params);
@@ -358,6 +345,119 @@
     return result.toResponse(request.id, requestTime);
   }
 
+  /// Handles files that might have been affected by a content change of
+  /// one or more files. The implementation may check if these files should
+  /// be analyzed, do such analysis, and send diagnostics.
+  ///
+  /// By default invokes [_analyzeFiles] only for files that are analyzed in
+  /// this [analysisContext].
+  Future<void> _handleAffectedFiles({
+    required AnalysisContext analysisContext,
+    required List<String> paths,
+  }) async {
+    var analyzedPaths = paths
+        .where(analysisContext.contextRoot.isAnalyzed)
+        .toList(growable: false);
+
+    await _analyzeFiles(
+      analysisContext: analysisContext,
+      paths: analyzedPaths,
+    );
+  }
+
+  /// Handles an 'analysis.setContextRoots' request.
+  Future<protocol.AnalysisSetContextRootsResult> _handleAnalysisSetContextRoots(
+      protocol.AnalysisSetContextRootsParams parameters) async {
+    var currentContextCollection = _contextCollection;
+    if (currentContextCollection != null) {
+      _contextCollection = null;
+      await currentContextCollection.dispose();
+    }
+
+    var includedPaths = parameters.roots.map((e) => e.root).toList();
+    var contextCollection = AnalysisContextCollectionImpl(
+      resourceProvider: _resourceProvider,
+      includedPaths: includedPaths,
+      byteStore: _byteStore,
+      sdkPath: _sdkPath,
+      fileContentCache: FileContentCache(_resourceProvider),
+    );
+    _contextCollection = contextCollection;
+    await _analyzeAllFilesInContextCollection(
+        contextCollection: contextCollection);
+    return protocol.AnalysisSetContextRootsResult();
+  }
+
+  /// Handles an 'analysis.updateContent' request.
+  ///
+  /// Throws a [RequestFailure] if the request could not be handled.
+  Future<protocol.AnalysisUpdateContentResult> _handleAnalysisUpdateContent(
+      protocol.AnalysisUpdateContentParams parameters) async {
+    var changedPaths = <String>{};
+    var paths = parameters.files;
+    paths.forEach((String path, Object overlay) {
+      // Prepare the old overlay contents.
+      String? oldContent;
+      try {
+        if (_resourceProvider.hasOverlay(path)) {
+          oldContent = _resourceProvider.getFile(path).readAsStringSync();
+        }
+      } catch (_) {
+        // Leave `oldContent` empty.
+      }
+
+      // Prepare the new contents.
+      String? newContent;
+      if (overlay is protocol.AddContentOverlay) {
+        newContent = overlay.content;
+      } else if (overlay is protocol.ChangeContentOverlay) {
+        if (oldContent == null) {
+          // The server should only send a ChangeContentOverlay if there is
+          // already an existing overlay for the source.
+          throw RequestFailure(
+              RequestErrorFactory.invalidOverlayChangeNoContent());
+        }
+        try {
+          newContent =
+              protocol.SourceEdit.applySequence(oldContent, overlay.edits);
+        } on RangeError {
+          throw RequestFailure(
+              RequestErrorFactory.invalidOverlayChangeInvalidEdit());
+        }
+      } else if (overlay is protocol.RemoveContentOverlay) {
+        newContent = null;
+      }
+
+      if (newContent != null) {
+        _resourceProvider.setOverlay(
+          path,
+          content: newContent,
+          modificationStamp: _overlayModificationStamp++,
+        );
+      } else {
+        _resourceProvider.removeOverlay(path);
+      }
+
+      changedPaths.add(path);
+    });
+    await _handleContentChanged(changedPaths.toList());
+    return protocol.AnalysisUpdateContentResult();
+  }
+
+  /// Handles the fact that files with [paths] were changed.
+  Future<void> _handleContentChanged(List<String> paths) async {
+    if (_contextCollection case var contextCollection?) {
+      await _forAnalysisContexts(contextCollection, (analysisContext) async {
+        for (var path in paths) {
+          analysisContext.changeFile(path);
+        }
+        var affected = await analysisContext.applyPendingFileChanges();
+        await _handleAffectedFiles(
+            analysisContext: analysisContext, paths: affected);
+      });
+    }
+  }
+
   Future<void> _handleRequest(Request request) async {
     var requestTime = DateTime.now().millisecondsSinceEpoch;
     var id = request.id;
diff --git a/pkg/analysis_server_plugin/test/src/plugin_server_test.dart b/pkg/analysis_server_plugin/test/src/plugin_server_test.dart
index 2f36449..c2e2679 100644
--- a/pkg/analysis_server_plugin/test/src/plugin_server_test.dart
+++ b/pkg/analysis_server_plugin/test/src/plugin_server_test.dart
@@ -12,6 +12,7 @@
 import 'package:analyzer_plugin/protocol/protocol_generated.dart' as protocol;
 import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart';
 import 'package:analyzer_plugin/utilities/fixes/fixes.dart';
+import 'package:async/async.dart';
 import 'package:test/test.dart';
 import 'package:test_reflective_loader/test_reflective_loader.dart';
 
@@ -24,6 +25,12 @@
 
 @reflectiveTest
 class PluginServerTest extends PluginServerTestBase {
+  protocol.ContextRoot get contextRoot => protocol.ContextRoot(packagePath, []);
+
+  String get filePath => join(packagePath, 'lib', 'test.dart');
+
+  String get packagePath => convertPath('/package1');
+
   @override
   Future<void> setUp() async {
     await super.setUp();
@@ -34,35 +41,20 @@
   }
 
   Future<void> test_handleAnalysisSetContextRoots() async {
-    var packagePath = convertPath('/package1');
-    var filePath = join(packagePath, 'lib', 'test.dart');
     newFile(filePath, 'bool b = false;');
-    var contextRoot = protocol.ContextRoot(packagePath, []);
-    await pluginServer.handleAnalysisSetContextRoots(
-        protocol.AnalysisSetContextRootsParams([contextRoot]));
+    await channel
+        .sendRequest(protocol.AnalysisSetContextRootsParams([contextRoot]));
     var notification = await channel.notifications.first;
     var params = protocol.AnalysisErrorsParams.fromNotification(notification);
     expect(params.file, convertPath('/package1/lib/test.dart'));
     expect(params.errors, hasLength(1));
-
-    expect(
-      params.errors.single,
-      isA<protocol.AnalysisError>()
-          .having((e) => e.severity, 'severity',
-              protocol.AnalysisErrorSeverity.INFO)
-          .having(
-              (e) => e.type, 'type', protocol.AnalysisErrorType.STATIC_WARNING)
-          .having((e) => e.message, 'message', 'No bools message'),
-    );
+    _expectAnalysisError(params.errors.single, message: 'No bools message');
   }
 
   Future<void> test_handleEditGetFixes() async {
-    var packagePath = convertPath('/package1');
-    var filePath = join(packagePath, 'lib', 'test.dart');
     newFile(filePath, 'bool b = false;');
-    var contextRoot = protocol.ContextRoot(packagePath, []);
-    await pluginServer.handleAnalysisSetContextRoots(
-        protocol.AnalysisSetContextRootsParams([contextRoot]));
+    await channel
+        .sendRequest(protocol.AnalysisSetContextRootsParams([contextRoot]));
 
     var result = await pluginServer.handleEditGetFixes(
         protocol.EditGetFixesParams(filePath, 'bool b = '.length));
@@ -73,6 +65,101 @@
     expect(fixes, hasLength(1));
     expect(fixes[0].fixes, hasLength(1));
   }
+
+  Future<void> test_updateContent_addOverlay() async {
+    newFile(filePath, 'int b = 7;');
+    await channel
+        .sendRequest(protocol.AnalysisSetContextRootsParams([contextRoot]));
+
+    var notifications = StreamQueue(channel.notifications);
+    var notification = await notifications.next;
+    var params = protocol.AnalysisErrorsParams.fromNotification(notification);
+    expect(params.file, convertPath('/package1/lib/test.dart'));
+    expect(params.errors, isEmpty);
+
+    await channel.sendRequest(protocol.AnalysisUpdateContentParams(
+        {filePath: protocol.AddContentOverlay('bool b = false;')}));
+
+    notification = await notifications.next;
+    params = protocol.AnalysisErrorsParams.fromNotification(notification);
+    expect(params.file, convertPath('/package1/lib/test.dart'));
+    expect(params.errors, hasLength(1));
+    _expectAnalysisError(params.errors.single, message: 'No bools message');
+  }
+
+  Future<void> test_updateContent_changeOverlay() async {
+    newFile(filePath, 'int b = 7;');
+    await channel
+        .sendRequest(protocol.AnalysisSetContextRootsParams([contextRoot]));
+
+    var notifications = StreamQueue(channel.notifications);
+    var notification = await notifications.next;
+    var params = protocol.AnalysisErrorsParams.fromNotification(notification);
+    expect(params.file, convertPath('/package1/lib/test.dart'));
+    expect(params.errors, isEmpty);
+
+    await channel.sendRequest(protocol.AnalysisUpdateContentParams(
+        {filePath: protocol.AddContentOverlay('int b = 0;')}));
+
+    notification = await notifications.next;
+    params = protocol.AnalysisErrorsParams.fromNotification(notification);
+    expect(params.file, convertPath('/package1/lib/test.dart'));
+    expect(params.errors, isEmpty);
+
+    await channel.sendRequest(protocol.AnalysisUpdateContentParams({
+      filePath: protocol.ChangeContentOverlay(
+          [protocol.SourceEdit(0, 9, 'bool b = false')])
+    }));
+
+    notification = await notifications.next;
+    params = protocol.AnalysisErrorsParams.fromNotification(notification);
+    expect(params.file, convertPath('/package1/lib/test.dart'));
+    expect(params.errors, hasLength(1));
+    _expectAnalysisError(params.errors.single, message: 'No bools message');
+  }
+
+  Future<void> test_updateContent_removeOverlay() async {
+    newFile(filePath, 'bool b = false;');
+    await channel
+        .sendRequest(protocol.AnalysisSetContextRootsParams([contextRoot]));
+
+    var notifications = StreamQueue(channel.notifications);
+    var notification = await notifications.next;
+    var params = protocol.AnalysisErrorsParams.fromNotification(notification);
+    expect(params.file, convertPath('/package1/lib/test.dart'));
+    expect(params.errors, hasLength(1));
+    _expectAnalysisError(params.errors.single, message: 'No bools message');
+
+    await channel.sendRequest(protocol.AnalysisUpdateContentParams(
+        {filePath: protocol.AddContentOverlay('int b = 7;')}));
+
+    notification = await notifications.next;
+    params = protocol.AnalysisErrorsParams.fromNotification(notification);
+    expect(params.file, convertPath('/package1/lib/test.dart'));
+    expect(params.errors, isEmpty);
+
+    await channel.sendRequest(protocol.AnalysisUpdateContentParams(
+        {filePath: protocol.RemoveContentOverlay()}));
+
+    notification = await notifications.next;
+    params = protocol.AnalysisErrorsParams.fromNotification(notification);
+    expect(params.file, convertPath('/package1/lib/test.dart'));
+    expect(params.errors, hasLength(1));
+    _expectAnalysisError(params.errors.single, message: 'No bools message');
+  }
+
+  void _expectAnalysisError(protocol.AnalysisError error,
+      {required String message}) {
+    expect(
+      error,
+      isA<protocol.AnalysisError>()
+          .having((e) => e.severity, 'severity',
+              protocol.AnalysisErrorSeverity.INFO)
+          .having(
+              (e) => e.type, 'type', protocol.AnalysisErrorType.STATIC_WARNING)
+          .having((e) => e.message, 'message', message),
+    );
+  }
 }
 
 class _NoBoolsPlugin extends Plugin {