From c3d80286fecd7a28ed5ea84c8f4294decadf1006 Mon Sep 17 00:00:00 2001 From: Danny Tuppeny Date: Thu, 1 Feb 2024 22:19:00 +0000 Subject: [PATCH] [analysis_server] Add a `supportsUris` client capability for legacy server to switch into URI mode This adds support for the legacy server to switch to URIs using a client capability. In this mode, all "FilePaths" will be URI strings over the protocol (in both directions). It also allows the server to send LSP notifications (wrapped inside an "lsp.notification" notification, matching how requests/responses work). Notifications are automatically enabled if the client uses any LSP methods or sends the new "supportsUris" capability. Change-Id: I5d2b76e396862129c61de70d57397603c958a02d Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/349120 Reviewed-by: Jaime Wren Reviewed-by: Brian Wilkerson Commit-Queue: Brian Wilkerson --- pkg/analysis_server/doc/api.html | 26 +++- .../lib/protocol/protocol_constants.dart | 6 +- .../lib/protocol/protocol_generated.dart | 96 +++++++++++- .../lib/src/analysis_server.dart | 58 +++++-- .../legacy/lsp_over_legacy_handler.dart | 1 + .../lib/src/legacy_analysis_server.dart | 47 +++++- ...r_dart_text_document_content_provider.dart | 6 +- .../lib/src/lsp/handlers/handlers.dart | 5 +- .../lib/src/lsp/lsp_analysis_server.dart | 30 +--- pkg/analysis_server/lib/src/lsp/progress.dart | 2 +- .../test/domain_server_test.dart | 147 +++++++++++++++++- .../test/integration/coverage.md | 1 + .../test/integration/coverage_test.dart | 9 +- .../lsp/abstract_lsp_over_legacy.dart | 65 ++++++++ .../test/integration/lsp/handle_test.dart | 52 +------ .../integration/lsp/notification_test.dart | 96 ++++++++++++ .../test/integration/lsp/test_all.dart | 6 +- .../support/integration_test_methods.dart | 39 ++++- .../support/protocol_matchers.dart | 14 +- .../test/lsp/request_helpers_mixin.dart | 13 ++ .../test/lsp/server_abstract.dart | 11 +- .../abstract_lsp_over_legacy.dart | 31 ++++ ...t_text_document_content_provider_test.dart | 104 +++++++++++++ .../test/lsp_over_legacy/test_all.dart | 3 + .../spec/generated/java/AnalysisServer.java | 8 +- pkg/analysis_server/tool/spec/spec_input.html | 39 ++++- .../lib/handler/notification_handler.dart | 7 + .../lib/src/protocol/protocol_constants.dart | 6 +- .../lib/src/protocol/protocol_generated.dart | 96 +++++++++++- .../src/utilities/client_uri_converter.dart | 10 +- 30 files changed, 910 insertions(+), 124 deletions(-) create mode 100644 pkg/analysis_server/test/integration/lsp/abstract_lsp_over_legacy.dart create mode 100644 pkg/analysis_server/test/integration/lsp/notification_test.dart create mode 100644 pkg/analysis_server/test/lsp_over_legacy/dart_text_document_content_provider_test.dart diff --git a/pkg/analysis_server/doc/api.html b/pkg/analysis_server/doc/api.html index 0e3703b01c9..b688b6a9660 100644 --- a/pkg/analysis_server/doc/api.html +++ b/pkg/analysis_server/doc/api.html @@ -110,7 +110,7 @@ a:focus, a:hover {

Analysis Server API Specification

Version - 1.35.0 + 1.36.0

This document contains a specification of the API provided by the @@ -245,6 +245,18 @@ a:focus, a:hover { ignoring the item or treating it with some default/fallback handling.

Changelog

+

1.36.0

+
    +
  • + Added a new supportsUris client capability to indicate that FilePaths should be URIs instead of file paths. +
  • +
  • + Added an experimental "lsp.handle" request to allow sending LSP requests through the protocol. +
  • +
  • + Added an experimental "lsp.notification" notification to allow receiving LSP notifications. +
  • +

1.35.0

  • @@ -558,6 +570,7 @@ a:focus, a:hover { "method": "server.setClientCapabilities" "params": { "requests": List<String> + "supportsUris": optional bool } }
    response: {
       "id": String
    @@ -593,6 +606,17 @@ a:focus, a:hover {
                 showMessageRequest
               
+
supportsUris: bool (optional)
+ +

+ True if the client supports the server sending URIs in place of file paths. +

+

+ In this mode, the server will use URIs in all protocol fields with the type FilePath. Returned URIs may be `file://` URIs or custom schemes. The client can fetch the file contents for URIs with custom schemes (and receive modification events) through the LSP protocol (see the "lsp" domain). +

+

+ LSP notifications are automatically enabled when the client sets this capability. +

server.openUrlRequest
request: {
   "id": String
   "method": "server.openUrlRequest"
diff --git a/pkg/analysis_server/lib/protocol/protocol_constants.dart b/pkg/analysis_server/lib/protocol/protocol_constants.dart
index e15a9142ce3..b27fb1ef191 100644
--- a/pkg/analysis_server/lib/protocol/protocol_constants.dart
+++ b/pkg/analysis_server/lib/protocol/protocol_constants.dart
@@ -6,7 +6,7 @@
 // To regenerate the file, use the script
 // "pkg/analysis_server/tool/spec/generate_files".
 
-const String PROTOCOL_VERSION = '1.35.0';
+const String PROTOCOL_VERSION = '1.36.0';
 
 const String ANALYSIS_NOTIFICATION_ANALYZED_FILES = 'analysis.analyzedFiles';
 const String ANALYSIS_NOTIFICATION_ANALYZED_FILES_DIRECTORIES = 'directories';
@@ -273,6 +273,8 @@ const String FLUTTER_REQUEST_SET_WIDGET_PROPERTY_VALUE_ID = 'id';
 const String FLUTTER_REQUEST_SET_WIDGET_PROPERTY_VALUE_VALUE = 'value';
 const String FLUTTER_RESPONSE_GET_WIDGET_DESCRIPTION_PROPERTIES = 'properties';
 const String FLUTTER_RESPONSE_SET_WIDGET_PROPERTY_VALUE_CHANGE = 'change';
+const String LSP_NOTIFICATION_NOTIFICATION = 'lsp.notification';
+const String LSP_NOTIFICATION_NOTIFICATION_LSP_NOTIFICATION = 'lspNotification';
 const String LSP_REQUEST_HANDLE = 'lsp.handle';
 const String LSP_REQUEST_HANDLE_LSP_MESSAGE = 'lspMessage';
 const String LSP_RESPONSE_HANDLE_LSP_RESPONSE = 'lspResponse';
@@ -334,6 +336,8 @@ const String SERVER_REQUEST_OPEN_URL_REQUEST_URL = 'url';
 const String SERVER_REQUEST_SET_CLIENT_CAPABILITIES =
     'server.setClientCapabilities';
 const String SERVER_REQUEST_SET_CLIENT_CAPABILITIES_REQUESTS = 'requests';
+const String SERVER_REQUEST_SET_CLIENT_CAPABILITIES_SUPPORTS_URIS =
+    'supportsUris';
 const String SERVER_REQUEST_SET_SUBSCRIPTIONS = 'server.setSubscriptions';
 const String SERVER_REQUEST_SET_SUBSCRIPTIONS_SUBSCRIPTIONS = 'subscriptions';
 const String SERVER_REQUEST_SHOW_MESSAGE_REQUEST = 'server.showMessageRequest';
diff --git a/pkg/analysis_server/lib/protocol/protocol_generated.dart b/pkg/analysis_server/lib/protocol/protocol_generated.dart
index 82197fa8562..bf372a97dfe 100644
--- a/pkg/analysis_server/lib/protocol/protocol_generated.dart
+++ b/pkg/analysis_server/lib/protocol/protocol_generated.dart
@@ -12028,6 +12028,66 @@ class LspHandleResult implements ResponseResult {
   int get hashCode => lspResponse.hashCode;
 }
 
+/// lsp.notification params
+///
+/// {
+///   "lspNotification": object
+/// }
+///
+/// Clients may not extend, implement or mix-in this class.
+class LspNotificationParams implements HasToJson {
+  /// The LSP NotificationMessage sent by the server.
+  Object lspNotification;
+
+  LspNotificationParams(this.lspNotification);
+
+  factory LspNotificationParams.fromJson(
+      JsonDecoder jsonDecoder, String jsonPath, Object? json) {
+    json ??= {};
+    if (json is Map) {
+      Object lspNotification;
+      if (json.containsKey('lspNotification')) {
+        lspNotification = json['lspNotification'] as Object;
+      } else {
+        throw jsonDecoder.mismatch(jsonPath, 'lspNotification');
+      }
+      return LspNotificationParams(lspNotification);
+    } else {
+      throw jsonDecoder.mismatch(jsonPath, 'lsp.notification params', json);
+    }
+  }
+
+  factory LspNotificationParams.fromNotification(Notification notification) {
+    return LspNotificationParams.fromJson(
+        ResponseDecoder(null), 'params', notification.params);
+  }
+
+  @override
+  Map toJson() {
+    var result = {};
+    result['lspNotification'] = lspNotification;
+    return result;
+  }
+
+  Notification toNotification() {
+    return Notification('lsp.notification', toJson());
+  }
+
+  @override
+  String toString() => json.encode(toJson());
+
+  @override
+  bool operator ==(other) {
+    if (other is LspNotificationParams) {
+      return lspNotification == other.lspNotification;
+    }
+    return false;
+  }
+
+  @override
+  int get hashCode => lspNotification.hashCode;
+}
+
 /// MessageAction
 ///
 /// {
@@ -15482,6 +15542,7 @@ class ServerService implements Enum {
 ///
 /// {
 ///   "requests": List
+///   "supportsUris": optional bool
 /// }
 ///
 /// Clients may not extend, implement or mix-in this class.
@@ -15501,7 +15562,20 @@ class ServerSetClientCapabilitiesParams implements RequestParams {
   /// - showMessageRequest
   List requests;
 
-  ServerSetClientCapabilitiesParams(this.requests);
+  /// True if the client supports the server sending URIs in place of file
+  /// paths.
+  ///
+  /// In this mode, the server will use URIs in all protocol fields with the
+  /// type FilePath. Returned URIs may be `file://` URIs or custom schemes. The
+  /// client can fetch the file contents for URIs with custom schemes (and
+  /// receive modification events) through the LSP protocol (see the "lsp"
+  /// domain).
+  ///
+  /// LSP notifications are automatically enabled when the client sets this
+  /// capability.
+  bool? supportsUris;
+
+  ServerSetClientCapabilitiesParams(this.requests, {this.supportsUris});
 
   factory ServerSetClientCapabilitiesParams.fromJson(
       JsonDecoder jsonDecoder, String jsonPath, Object? json) {
@@ -15514,7 +15588,13 @@ class ServerSetClientCapabilitiesParams implements RequestParams {
       } else {
         throw jsonDecoder.mismatch(jsonPath, 'requests');
       }
-      return ServerSetClientCapabilitiesParams(requests);
+      bool? supportsUris;
+      if (json.containsKey('supportsUris')) {
+        supportsUris = jsonDecoder.decodeBool(
+            '$jsonPath.supportsUris', json['supportsUris']);
+      }
+      return ServerSetClientCapabilitiesParams(requests,
+          supportsUris: supportsUris);
     } else {
       throw jsonDecoder.mismatch(
           jsonPath, 'server.setClientCapabilities params', json);
@@ -15530,6 +15610,10 @@ class ServerSetClientCapabilitiesParams implements RequestParams {
   Map toJson() {
     var result = {};
     result['requests'] = requests;
+    var supportsUris = this.supportsUris;
+    if (supportsUris != null) {
+      result['supportsUris'] = supportsUris;
+    }
     return result;
   }
 
@@ -15545,13 +15629,17 @@ class ServerSetClientCapabilitiesParams implements RequestParams {
   bool operator ==(other) {
     if (other is ServerSetClientCapabilitiesParams) {
       return listEqual(
-          requests, other.requests, (String a, String b) => a == b);
+              requests, other.requests, (String a, String b) => a == b) &&
+          supportsUris == other.supportsUris;
     }
     return false;
   }
 
   @override
-  int get hashCode => Object.hashAll(requests);
+  int get hashCode => Object.hash(
+        Object.hashAll(requests),
+        supportsUris,
+      );
 }
 
 /// server.setClientCapabilities result
diff --git a/pkg/analysis_server/lib/src/analysis_server.dart b/pkg/analysis_server/lib/src/analysis_server.dart
index df80ceca931..c0532fde9f7 100644
--- a/pkg/analysis_server/lib/src/analysis_server.dart
+++ b/pkg/analysis_server/lib/src/analysis_server.dart
@@ -6,8 +6,7 @@ import 'dart:async';
 import 'dart:io' as io;
 import 'dart:io';
 
-import 'package:analysis_server/lsp_protocol/protocol.dart' as lsp
-    show MessageType, OptionalVersionedTextDocumentIdentifier;
+import 'package:analysis_server/lsp_protocol/protocol.dart' as lsp;
 import 'package:analysis_server/src/analytics/analytics_manager.dart';
 import 'package:analysis_server/src/collections.dart';
 import 'package:analysis_server/src/context_manager.dart';
@@ -15,6 +14,7 @@ import 'package:analysis_server/src/domains/completion/available_suggestions.dar
 import 'package:analysis_server/src/legacy_analysis_server.dart';
 import 'package:analysis_server/src/lsp/client_capabilities.dart';
 import 'package:analysis_server/src/lsp/client_configuration.dart';
+import 'package:analysis_server/src/lsp/constants.dart' as lsp;
 import 'package:analysis_server/src/plugin/notification_manager.dart';
 import 'package:analysis_server/src/plugin/plugin_manager.dart';
 import 'package:analysis_server/src/plugin/plugin_watcher.dart';
@@ -74,7 +74,8 @@ import 'package:analyzer/src/util/file_paths.dart' as file_paths;
 import 'package:analyzer/src/util/performance/operation_performance.dart';
 import 'package:analyzer/src/utilities/extensions/analysis_session.dart';
 import 'package:analyzer_plugin/protocol/protocol.dart';
-import 'package:analyzer_plugin/src/protocol/protocol_internal.dart';
+import 'package:analyzer_plugin/src/protocol/protocol_internal.dart'
+    as analyzer_plugin;
 import 'package:analyzer_plugin/src/utilities/client_uri_converter.dart';
 import 'package:collection/collection.dart';
 import 'package:http/http.dart' as http;
@@ -217,10 +218,6 @@ abstract class AnalysisServer {
   /// the last idle state.
   final Set filesResolvedSinceLastIdle = {};
 
-  /// A converter to change incoming client URIs into analyzer file references
-  /// (and back).
-  ClientUriConverter uriConverter;
-
   /// A mapping of [ProducerGenerator]s to the set of lint names with which they
   /// are associated (can fix).
   final Map> producerGeneratorsForLintRules;
@@ -240,11 +237,13 @@ abstract class AnalysisServer {
     bool enableBlazeWatcher = false,
     DartFixPromptManager? dartFixPromptManager,
   })  : resourceProvider = OverlayResourceProvider(baseResourceProvider),
-        uriConverter =
-            ClientUriConverter.noop(baseResourceProvider.pathContext),
         pubApi = PubApi(instrumentationService, httpClient,
             Platform.environment['PUB_HOSTED_URL']),
         producerGeneratorsForLintRules = AssistProcessor.computeLintRuleMap() {
+    // Set the default URI converter. This uses the resource providers path
+    // context (unlike the initialized value) which allows tests to override it.
+    uriConverter = ClientUriConverter.noop(baseResourceProvider.pathContext);
+
     // We can only spawn processes (eg. to run pub commands) when backed by
     // a real file system, otherwise we may try to run commands in folders that
     // don't really exist. If processRunner was supplied, it's likely a mock
@@ -389,6 +388,21 @@ abstract class AnalysisServer {
     return DateTime.now().difference(start);
   }
 
+  /// Gets the converter to change incoming client URIs into analyzer file
+  /// references (and back).
+  ///
+  /// Currently backed by a global for use by toJson/fromJson in the legacy
+  /// protocol classes.
+  ClientUriConverter get uriConverter => analyzer_plugin.clientUriConverter;
+
+  /// Sets the converter to change incoming client URIs into analyzer file
+  /// references (and back).
+  ///
+  /// Currently backed by a global for use by toJson/fromJson in the legacy
+  /// protocol classes.
+  set uriConverter(ClientUriConverter converter) =>
+      analyzer_plugin.clientUriConverter = converter;
+
   /// Returns the function for sending prompts to the user and collecting button
   /// presses.
   ///
@@ -418,7 +432,8 @@ abstract class AnalysisServer {
   /// the plugins have sent a response, or an empty list if no [driver] is
   /// provided.
   Map> broadcastRequestToPlugins(
-      RequestParams requestParams, analysis.AnalysisDriver? driver) {
+      analyzer_plugin.RequestParams requestParams,
+      analysis.AnalysisDriver? driver) {
     if (driver == null || !AnalysisServer.supportsPlugins) {
       return >{};
     }
@@ -828,6 +843,12 @@ abstract class AnalysisServer {
     return null;
   }
 
+  /// Sends an LSP notification to the client.
+  ///
+  /// The legacy server will wrap LSP notifications inside an
+  /// 'lsp.notification' notification.
+  void sendLspNotification(lsp.NotificationMessage notification);
+
   /// Sends an error notification to the user.
   void sendServerErrorNotification(
     String message,
@@ -957,6 +978,23 @@ abstract class CommonServerContextManagerCallbacks
       analysisServer.filesResolvedSinceLastIdle.add(path);
       handleResolvedUnitResult(result);
     }
+
+    // If this is a virtual file and the client supports URIs, we need to notify
+    // that it's been updated.
+    var lspUri = analysisServer.uriConverter.toClientUri(result.path);
+    if (!lspUri.isScheme('file')) {
+      // TODO(dantup): Should we do any kind of tracking here to avoid sending
+      //  lots of notifications if there aren't actual changes?
+      // TODO(dantup): We may be able to skip sending this if the file is not
+      //  open (priority) depending on the response to
+      //  https://github.com/microsoft/vscode/issues/202017
+      var message = lsp.NotificationMessage(
+        method: lsp.CustomMethods.dartTextDocumentContentDidChange,
+        params: lsp.DartTextDocumentContentDidChangeParams(uri: lspUri),
+        jsonrpc: lsp.jsonRpcVersion,
+      );
+      analysisServer.sendLspNotification(message);
+    }
   }
 
   void handleResolvedUnitResult(ResolvedUnitResult result);
diff --git a/pkg/analysis_server/lib/src/handler/legacy/lsp_over_legacy_handler.dart b/pkg/analysis_server/lib/src/handler/legacy/lsp_over_legacy_handler.dart
index 703ae91c66b..620630bb45e 100644
--- a/pkg/analysis_server/lib/src/handler/legacy/lsp_over_legacy_handler.dart
+++ b/pkg/analysis_server/lib/src/handler/legacy/lsp_over_legacy_handler.dart
@@ -38,6 +38,7 @@ class LspOverLegacyHandler extends LegacyHandler {
 
   @override
   Future handle() async {
+    server.sendLspNotifications = true;
     final params = LspHandleParams.fromRequest(request);
     final lspMessageJson = params.lspMessage;
     final reporter = LspJsonReporter();
diff --git a/pkg/analysis_server/lib/src/legacy_analysis_server.dart b/pkg/analysis_server/lib/src/legacy_analysis_server.dart
index 9482c2a7e5b..f22ed840774 100644
--- a/pkg/analysis_server/lib/src/legacy_analysis_server.dart
+++ b/pkg/analysis_server/lib/src/legacy_analysis_server.dart
@@ -107,6 +107,7 @@ import 'package:analyzer/src/util/file_paths.dart' as file_paths;
 import 'package:analyzer/src/util/performance/operation_performance.dart';
 import 'package:analyzer/src/utilities/cancellation.dart';
 import 'package:analyzer_plugin/protocol/protocol_common.dart' hide Element;
+import 'package:analyzer_plugin/src/utilities/client_uri_converter.dart';
 import 'package:analyzer_plugin/src/utilities/navigation/navigation.dart';
 import 'package:analyzer_plugin/utilities/navigation/navigation_dart.dart';
 import 'package:http/http.dart' as http;
@@ -171,7 +172,7 @@ class LegacyAnalysisServer extends AnalysisServer {
   /// handler.
   ///
   /// Requests that don't match anything in this map will be passed to
-  /// [_LspOverLegacyHandler] for possible handling before returning an error.
+  /// [LspOverLegacyHandler] for possible handling before returning an error.
   static final Map requestHandlerGenerators = {
     ANALYSIS_REQUEST_GET_ERRORS: AnalysisGetErrorsHandler.new,
     ANALYSIS_REQUEST_GET_HOVER: AnalysisGetHoverHandler.new,
@@ -282,8 +283,10 @@ class LegacyAnalysisServer extends AnalysisServer {
   Map> analysisServices = {};
 
   /// The most recently registered set of client capabilities. The default is to
-  /// have no registered requests.
-  ServerSetClientCapabilitiesParams clientCapabilities =
+  /// have no registered requests and no additional capabilities.
+  ///
+  /// Must be modified through the [clientCapabilities] setter.
+  ServerSetClientCapabilitiesParams _clientCapabilities =
       ServerSetClientCapabilitiesParams([]);
 
   @override
@@ -362,6 +365,12 @@ class LegacyAnalysisServer extends AnalysisServer {
   /// response when it has been received.
   Map> pendingServerRequests = {};
 
+  /// Whether the server should send LSP notifications.
+  ///
+  /// This is set once the client sends any LSP request or client capability
+  /// that depends on LSP functionality.
+  bool sendLspNotifications = false;
+
   /// Initialize a newly created server to receive requests from and send
   /// responses to the given [channel].
   ///
@@ -426,6 +435,26 @@ class LegacyAnalysisServer extends AnalysisServer {
     _newRefactoringManager();
   }
 
+  /// The most recently registered set of client capabilities. The default is to
+  /// have no registered requests and no additional capabilities.
+  ServerSetClientCapabilitiesParams get clientCapabilities =>
+      _clientCapabilities;
+
+  /// Updates the current set of client capabilities.
+  set clientCapabilities(ServerSetClientCapabilitiesParams capabilities) {
+    _clientCapabilities = capabilities;
+
+    if (capabilities.supportsUris ?? false) {
+      // URI support implies LSP, as that's the only way to access (and get
+      // change notifications for) custom-scheme files.
+      sendLspNotifications = true;
+      uriConverter = ClientUriConverter.withVirtualFileSupport(
+          resourceProvider.pathContext);
+    } else {
+      uriConverter = ClientUriConverter.noop(resourceProvider.pathContext);
+    }
+  }
+
   /// The [Future] that completes when analysis is complete.
   Future get onAnalysisComplete {
     if (isAnalysisComplete()) {
@@ -627,6 +656,18 @@ class LegacyAnalysisServer extends AnalysisServer {
     flutterWidgetDescriptions.flush();
   }
 
+  /// Send the given LSP [notification] to the client.
+  @override
+  void sendLspNotification(lsp.NotificationMessage notification) {
+    if (!sendLspNotifications) {
+      return;
+    }
+
+    channel.sendNotification(
+      LspNotificationParams(notification).toNotification(),
+    );
+  }
+
   /// Send the given [notification] to the client.
   void sendNotification(Notification notification) {
     channel.sendNotification(notification);
diff --git a/pkg/analysis_server/lib/src/lsp/handlers/handler_dart_text_document_content_provider.dart b/pkg/analysis_server/lib/src/lsp/handlers/handler_dart_text_document_content_provider.dart
index cdffb83f785..a6877a9f05b 100644
--- a/pkg/analysis_server/lib/src/lsp/handlers/handler_dart_text_document_content_provider.dart
+++ b/pkg/analysis_server/lib/src/lsp/handlers/handler_dart_text_document_content_provider.dart
@@ -30,11 +30,13 @@ class DartTextDocumentContentProviderHandler extends SharedMessageHandler<
     var uri = params.uri;
 
     if (!allowedSchemes.contains(uri.scheme)) {
+      var supportedSchemesString = allowedSchemes.isEmpty
+          ? '(none)'
+          : allowedSchemes.map((scheme) => "'$scheme'").join(', ');
       return error(
         ErrorCodes.InvalidParams,
         "Fetching content for scheme '${uri.scheme}' is not supported. "
-        'Supported schemes are '
-        '${allowedSchemes.map((scheme) => "'$scheme'").join(', ')}.',
+        'Supported schemes are $supportedSchemesString.',
       );
     }
 
diff --git a/pkg/analysis_server/lib/src/lsp/handlers/handlers.dart b/pkg/analysis_server/lib/src/lsp/handlers/handlers.dart
index 93807171997..4e137897bbb 100644
--- a/pkg/analysis_server/lib/src/lsp/handlers/handlers.dart
+++ b/pkg/analysis_server/lib/src/lsp/handlers/handlers.dart
@@ -124,8 +124,9 @@ mixin HandlerHelperMixin {
     var supportedSchemes = server.uriConverter.supportedSchemes;
     var isValidScheme = supportedSchemes.contains(uri.scheme);
     if (!isValidScheme) {
-      var supportedSchemesString =
-          supportedSchemes.map((scheme) => "'$scheme'").join(', ');
+      var supportedSchemesString = supportedSchemes.isEmpty
+          ? '(none)'
+          : supportedSchemes.map((scheme) => "'$scheme'").join(', ');
       return ErrorOr.error(ResponseError(
         code: ServerErrorCodes.InvalidFilePath,
         message: "URI scheme '${uri.scheme}' is not supported. "
diff --git a/pkg/analysis_server/lib/src/lsp/lsp_analysis_server.dart b/pkg/analysis_server/lib/src/lsp/lsp_analysis_server.dart
index 8cd1c0a7620..070bff4d321 100644
--- a/pkg/analysis_server/lib/src/lsp/lsp_analysis_server.dart
+++ b/pkg/analysis_server/lib/src/lsp/lsp_analysis_server.dart
@@ -244,7 +244,7 @@ class LspAnalysisServer extends AnalysisServer {
         params: params,
         jsonrpc: jsonRpcVersion,
       );
-      sendNotification(message);
+      sendLspNotification(message);
     };
   }
 
@@ -637,7 +637,7 @@ class LspAnalysisServer extends AnalysisServer {
       params: params,
       jsonrpc: jsonRpcVersion,
     );
-    sendNotification(message);
+    sendLspNotification(message);
   }
 
   void publishDiagnostics(String path, List errors) {
@@ -659,7 +659,7 @@ class LspAnalysisServer extends AnalysisServer {
       params: params,
       jsonrpc: jsonRpcVersion,
     );
-    sendNotification(message);
+    sendLspNotification(message);
   }
 
   void publishFlutterOutline(String path, FlutterOutline outline) {
@@ -670,7 +670,7 @@ class LspAnalysisServer extends AnalysisServer {
       params: params,
       jsonrpc: jsonRpcVersion,
     );
-    sendNotification(message);
+    sendLspNotification(message);
   }
 
   void publishOutline(String path, Outline outline) {
@@ -681,7 +681,7 @@ class LspAnalysisServer extends AnalysisServer {
       params: params,
       jsonrpc: jsonRpcVersion,
     );
-    sendNotification(message);
+    sendLspNotification(message);
   }
 
   Future removePriorityFile(String path) async {
@@ -722,7 +722,8 @@ class LspAnalysisServer extends AnalysisServer {
   }
 
   /// Send the given [notification] to the client.
-  void sendNotification(NotificationMessage notification) {
+  @override
+  void sendLspNotification(NotificationMessage notification) {
     channel.sendNotification(notification);
   }
 
@@ -1159,23 +1160,6 @@ class LspServerContextManagerCallbacks
       return;
     }
 
-    // If this is a virtual file, we need to notify the client that it's been
-    // updated.
-    var lspUri = analysisServer.uriConverter.toClientUri(result.path);
-    if (!lspUri.isScheme('file')) {
-      // TODO(dantup): Should we do any kind of tracking here to avoid sending
-      //  lots of notifications if there aren't actual changes?
-      // TODO(dantup): We may be able to skip sending this if the file is not
-      //  open (priority) depending on the response to
-      //  https://github.com/microsoft/vscode/issues/202017
-      var message = NotificationMessage(
-        method: CustomMethods.dartTextDocumentContentDidChange,
-        params: DartTextDocumentContentDidChangeParams(uri: lspUri),
-        jsonrpc: jsonRpcVersion,
-      );
-      analysisServer.sendNotification(message);
-    }
-
     super.handleFileResult(result);
   }
 
diff --git a/pkg/analysis_server/lib/src/lsp/progress.dart b/pkg/analysis_server/lib/src/lsp/progress.dart
index 17b3e3ab3a1..70599143389 100644
--- a/pkg/analysis_server/lib/src/lsp/progress.dart
+++ b/pkg/analysis_server/lib/src/lsp/progress.dart
@@ -121,7 +121,7 @@ class _TokenProgressReporter extends ProgressReporter {
   }
 
   void _sendNotification(ToJsonable value) async {
-    _server.sendNotification(NotificationMessage(
+    _server.sendLspNotification(NotificationMessage(
         method: Method.progress,
         params: ProgressParams(
           token: _token,
diff --git a/pkg/analysis_server/test/domain_server_test.dart b/pkg/analysis_server/test/domain_server_test.dart
index 5bc1745a98e..fccd17e8804 100644
--- a/pkg/analysis_server/test/domain_server_test.dart
+++ b/pkg/analysis_server/test/domain_server_test.dart
@@ -92,7 +92,7 @@ class ServerDomainTest extends PubPackageAnalysisServerTest {
     await responseFuture;
   }
 
-  Future test_setClientCapabilities() async {
+  Future test_setClientCapabilities_requests() async {
     var requestId = -1;
 
     Future setCapabilities(
@@ -124,6 +124,151 @@ class ServerDomainTest extends PubPackageAnalysisServerTest {
     await setCapabilities(openUrlRequest: false, showMessageRequest: false);
   }
 
+  /// Verify that the server handles URIs once we've enabled the supportsUris
+  /// client capability.
+  Future
+      test_setClientCapabilities_supportsUris_clientToServer_request() async {
+    // Tell the server we support URIs.
+    await handleSuccessfulRequest(
+        ServerSetClientCapabilitiesParams([], supportsUris: true)
+            .toRequest('-1'));
+
+    // Set the roots using a URI. Since the helper methods will to through
+    // toJson() (which will convert paths to URIs) we need to pass the JSON
+    // manually here.
+    await handleSuccessfulRequest(Request('1', 'analysis.setAnalysisRoots', {
+      'included': [toUri(workspaceRootPath).toString()],
+      'excluded': [],
+    }));
+    await pumpEventQueue(times: 5000);
+
+    // Ensure the roots were recorded correctly.
+    expect(
+      server.contextManager.includedPaths,
+      [convertPath(workspaceRootPath)],
+    );
+  }
+
+  Future test_setClientCapabilities_supportsUris_defaults() async {
+    // Before request.
+    expect(server.clientCapabilities.supportsUris, isNull);
+    expect(server.uriConverter.supportedNonFileSchemes, isEmpty);
+
+    // If not supplied.
+    await handleSuccessfulRequest(
+        ServerSetClientCapabilitiesParams([]).toRequest('-1'));
+    expect(server.clientCapabilities.supportsUris, isNull);
+    expect(server.uriConverter.supportedNonFileSchemes, isEmpty);
+
+    // If set explicitly to false.
+    await handleSuccessfulRequest(
+        ServerSetClientCapabilitiesParams([], supportsUris: false)
+            .toRequest('-1'));
+    expect(server.clientCapabilities.supportsUris, isFalse);
+    expect(server.uriConverter.supportedNonFileSchemes, isEmpty);
+  }
+
+  Future
+      test_setClientCapabilities_supportsUris_false_rejectsUris() async {
+    // Explicitly tell the server we do not support URIs.
+    await handleSuccessfulRequest(
+        ServerSetClientCapabilitiesParams([], supportsUris: false)
+            .toRequest('-1'));
+
+    // Try to send a URI anyway.
+    var request = Request('1', 'analysis.setAnalysisRoots', {
+      'included': [toUri(workspaceRootPath).toString()],
+      'excluded': [],
+    });
+    var response = await handleRequest(request);
+    expect(
+        response,
+        isResponseFailure(
+            request.id, RequestErrorCode.INVALID_FILE_PATH_FORMAT));
+  }
+
+  /// Verify that the server uses URIs in notifications once we've enabled the
+  /// supportsUris client capability.
+  Future
+      test_setClientCapabilities_supportsUris_serverToClient_notification() async {
+    // Add a file with an error for testing.
+    var testFilePath = convertPath('$testPackageLibPath/test.dart');
+    var testFileUriString = toUri(testFilePath).toString();
+    newFile(testFilePath, 'broken');
+
+    // Tell the server we support URIs before analysis starts since we will
+    // verify the analysis.errors notification.
+    await handleSuccessfulRequest(
+        ServerSetClientCapabilitiesParams([], supportsUris: true)
+            .toRequest('10'));
+    await pumpEventQueue(times: 5000);
+
+    // Trigger analysis.
+    await setRoots(
+      // We can use paths here because toJson() will handle the conversion.
+      included: [workspaceRootPath],
+      excluded: [],
+    );
+    await pumpEventQueue(times: 5000);
+
+    // Verify the last error for this file was using a URI.
+    var lastErrorFile = serverChannel.notificationsReceived
+        .where((notification) => notification.event == 'analysis.errors')
+        .map((notification) => notification.params!['file'] as String)
+        .lastWhere((filePath) => filePath.endsWith('test.dart'));
+    expect(
+      lastErrorFile,
+      testFileUriString,
+    );
+  }
+
+  /// Verify that the server returns URIs once we've enabled the supportsUris
+  /// client capability.
+  Future
+      test_setClientCapabilities_supportsUris_serverToClient_response() async {
+    // Add a file with an error for testing.
+    var testFilePath = convertPath('$testPackageLibPath/test.dart');
+    var testFileUriString = toUri(testFilePath).toString();
+    newFile(testFilePath, 'broken');
+
+    await setRoots(included: [workspaceRootPath], excluded: []);
+    await pumpEventQueue(times: 5000);
+
+    // Tell the server we support URIs.
+    await handleSuccessfulRequest(
+        ServerSetClientCapabilitiesParams([], supportsUris: true)
+            .toRequest('10'));
+    await pumpEventQueue(times: 5000);
+
+    // Send a GetErrors request. The response has nested FilePaths inside the
+    // AnalysisErrors so will confirm the server mapped correctly.
+    var response =
+        await handleSuccessfulRequest(Request('1', 'analysis.getErrors', {
+      'file': testFileUriString,
+    }));
+    // Verify the error location was the expected URI.
+    expect(
+      (response.result as dynamic)['errors'][0]!['location']['file'],
+      testFileUriString,
+    );
+  }
+
+  Future
+      test_setClientCapabilities_supportsUris_unspecified_rejectsUris() async {
+    // Do not tell the server we support URIs.
+
+    // Try to send a URI anyway.
+    var request = Request('1', 'analysis.setAnalysisRoots', {
+      'included': [toUri(workspaceRootPath).toString()],
+      'excluded': [],
+    });
+    var response = await handleRequest(request);
+    expect(
+        response,
+        isResponseFailure(
+            request.id, RequestErrorCode.INVALID_FILE_PATH_FORMAT));
+  }
+
   Future test_setSubscriptions_invalidServiceName() async {
     var request = Request('0', SERVER_REQUEST_SET_SUBSCRIPTIONS, {
       SUBSCRIPTIONS: ['noSuchService']
diff --git a/pkg/analysis_server/test/integration/coverage.md b/pkg/analysis_server/test/integration/coverage.md
index 77dabad49ae..0362cb75e37 100644
--- a/pkg/analysis_server/test/integration/coverage.md
+++ b/pkg/analysis_server/test/integration/coverage.md
@@ -105,3 +105,4 @@ server calls. This file is validated by `coverage_test.dart`.
 
 ## lsp domain
 - [x] lsp.handle
+- [x] lsp.notification
diff --git a/pkg/analysis_server/test/integration/coverage_test.dart b/pkg/analysis_server/test/integration/coverage_test.dart
index d7dbb39ab4c..410275a2b16 100644
--- a/pkg/analysis_server/test/integration/coverage_test.dart
+++ b/pkg/analysis_server/test/integration/coverage_test.dart
@@ -110,9 +110,12 @@ void main() {
 
             // Test that if checked, a test file exists; if not checked, no such
             // file exists.
-            expect(FileSystemEntity.isFileSync(testPath),
-                coveredMembers.contains(fullName),
-                reason: '$testName state incorrect');
+            var fileExists = FileSystemEntity.isFileSync(testPath);
+            var isMarkedAsCovered = coveredMembers.contains(fullName);
+            expect(fileExists, isMarkedAsCovered,
+                reason: isMarkedAsCovered
+                    ? '$testName marked as covered but has no test at $testPath'
+                    : '$testName marked as not covered has test at $testPath');
           });
         }
       });
diff --git a/pkg/analysis_server/test/integration/lsp/abstract_lsp_over_legacy.dart b/pkg/analysis_server/test/integration/lsp/abstract_lsp_over_legacy.dart
new file mode 100644
index 00000000000..7d2a874d0f7
--- /dev/null
+++ b/pkg/analysis_server/test/integration/lsp/abstract_lsp_over_legacy.dart
@@ -0,0 +1,65 @@
+// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file
+// 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 'dart:async';
+
+import 'package:analysis_server/lsp_protocol/protocol.dart';
+import 'package:analyzer_plugin/src/utilities/client_uri_converter.dart';
+import 'package:test/test.dart';
+
+import '../../lsp/request_helpers_mixin.dart';
+import '../support/integration_tests.dart';
+
+abstract class AbstractLspOverLegacyTest
+    extends AbstractAnalysisServerIntegrationTest
+    with LspRequestHelpersMixin, LspEditHelpersMixin {
+  late final testFile = sourcePath('lib/test.dart');
+
+  /// A stream of LSP [NotificationMessage]s from the server.
+  @override
+  Stream get notificationsFromServer =>
+      onLspNotification.map((params) => NotificationMessage.fromJson(
+          params.lspNotification as Map));
+
+  /// The URI for the macro-generated content for [testFileUri].
+  Uri get testFileMacroUri =>
+      Uri.file(testFile).replace(scheme: macroClientUriScheme);
+
+  Uri get testFileUri => Uri.file(testFile);
+
+  void expectMarkdown(
+    Either2 contents,
+    String expected,
+  ) {
+    final markup = contents.map(
+      (t1) => t1,
+      (t2) => throw 'Hover contents were String, not MarkupContent',
+    );
+
+    expect(markup.kind, MarkupKind.Markdown);
+    expect(markup.value.trimRight(), expected.trimRight());
+  }
+
+  @override
+  Future expectSuccessfulResponseTo(
+    RequestMessage message,
+    T Function(R) fromJson,
+  ) async {
+    final legacyResult = await sendLspHandle(message.toJson());
+    final lspResponseJson = legacyResult.lspResponse as Map;
+
+    // Unwrap the LSP response.
+    final lspResponse = ResponseMessage.fromJson(lspResponseJson);
+    final error = lspResponse.error;
+    if (error != null) {
+      throw error;
+    } else if (T == Null) {
+      return lspResponse.result == null
+          ? null as T
+          : throw 'Expected Null response but got ${lspResponse.result}';
+    } else {
+      return fromJson(lspResponse.result as R);
+    }
+  }
+}
diff --git a/pkg/analysis_server/test/integration/lsp/handle_test.dart b/pkg/analysis_server/test/integration/lsp/handle_test.dart
index 753df072ca0..1b66121f132 100644
--- a/pkg/analysis_server/test/integration/lsp/handle_test.dart
+++ b/pkg/analysis_server/test/integration/lsp/handle_test.dart
@@ -10,55 +10,28 @@ import 'package:analyzer/src/test_utilities/test_code_format.dart';
 import 'package:test/test.dart';
 import 'package:test_reflective_loader/test_reflective_loader.dart';
 
-import '../../lsp/request_helpers_mixin.dart';
 import '../../tool/lsp_spec/matchers.dart';
 import '../../utils/test_code_extensions.dart';
 import '../support/integration_tests.dart';
+import 'abstract_lsp_over_legacy.dart';
 
 void main() {
   defineReflectiveSuite(() {
-    defineReflectiveTests(LspOverLegacyTest);
+    defineReflectiveTests(LspOverLegacyRequestTest);
   });
 }
 
-/// Integration tests for using LSP over the Legacy protocol.
+/// Integration tests for sending LSP requests over the Legacy protocol.
 ///
 /// These tests are slow (each test spawns an out-of-process server) so these
 /// tests are intended only to ensure the basic functionality is available and
-/// not to test all handlers/functionality already are covered by LSP tests.
+/// not to test all handlers/functionality already covered by LSP tests.
 ///
 /// Additional tests (to verify each expected LSP handler is available over
 /// Legacy) are in `test/lsp_over_legacy/` and tests for all handler
 /// functionality are in `test/lsp`.
 @reflectiveTest
-class LspOverLegacyTest extends AbstractAnalysisServerIntegrationTest
-    with LspRequestHelpersMixin, LspEditHelpersMixin {
-  late final testFile = sourcePath('lib/test.dart');
-
-  Uri get testFileUri => Uri.file(testFile);
-
-  @override
-  Future expectSuccessfulResponseTo(
-    RequestMessage message,
-    T Function(R) fromJson,
-  ) async {
-    final legacyResult = await sendLspHandle(message.toJson());
-    final lspResponseJson = legacyResult.lspResponse as Map;
-
-    // Unwrap the LSP response.
-    final lspResponse = ResponseMessage.fromJson(lspResponseJson);
-    final error = lspResponse.error;
-    if (error != null) {
-      throw error;
-    } else if (T == Null) {
-      return lspResponse.result == null
-          ? null as T
-          : throw 'Expected Null response but got ${lspResponse.result}';
-    } else {
-      return fromJson(lspResponse.result as R);
-    }
-  }
-
+class LspOverLegacyRequestTest extends AbstractLspOverLegacyTest {
   Future test_error_invalidLspRequest() async {
     await standardAnalysisSetup();
     await analysisFinished;
@@ -112,7 +85,7 @@ class [!A^aa!] {}
     final result = await getHover(testFileUri, code.position.position);
 
     expect(result!.range, code.range.range);
-    _expectMarkdown(
+    expectMarkdown(
       result.contents,
       '''
 ```dart
@@ -173,17 +146,4 @@ This is my class.''';
       }
     });
   }
-
-  void _expectMarkdown(
-    Either2 contents,
-    String expected,
-  ) {
-    final markup = contents.map(
-      (t1) => t1,
-      (t2) => throw 'Hover contents were String, not MarkupContent',
-    );
-
-    expect(markup.kind, MarkupKind.Markdown);
-    expect(markup.value.trimRight(), expected.trimRight());
-  }
 }
diff --git a/pkg/analysis_server/test/integration/lsp/notification_test.dart b/pkg/analysis_server/test/integration/lsp/notification_test.dart
new file mode 100644
index 00000000000..0a9a2873c78
--- /dev/null
+++ b/pkg/analysis_server/test/integration/lsp/notification_test.dart
@@ -0,0 +1,96 @@
+// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file
+// 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 'dart:async';
+
+import 'package:analysis_server/src/lsp/test_macros.dart';
+import 'package:analyzer/src/util/file_paths.dart' as file_paths;
+import 'package:analyzer_plugin/src/protocol/protocol_internal.dart'
+    as analyzer_plugin;
+import 'package:analyzer_plugin/src/utilities/client_uri_converter.dart';
+import 'package:test_reflective_loader/test_reflective_loader.dart';
+
+import '../../analysis_server_base.dart';
+import 'abstract_lsp_over_legacy.dart';
+
+void main() {
+  defineReflectiveSuite(() {
+    defineReflectiveTests(LspOverLegacyNotificationTest);
+  });
+}
+
+/// Integration tests for receiving LSP notifications over the Legacy protocol.
+///
+/// These tests are slow (each test spawns an out-of-process server) so these
+/// tests are intended only to ensure the basic functionality is available and
+/// not to test all handlers/functionality already covered by LSP tests.
+///
+/// Additional tests (to verify each expected LSP handler is available over
+/// Legacy) are in `test/lsp_over_legacy/` and tests for all handler
+/// functionality are in `test/lsp`.
+@reflectiveTest
+class LspOverLegacyNotificationTest extends AbstractLspOverLegacyTest
+    with TestMacros {
+  /// Tells the server we support custom URIs, otherwise we won't be allowed to
+  /// fetch any content from a URI.
+  Future enableCustomUriSupport() async {
+    // Tell the server we will be using URIs.
+    await sendServerSetClientCapabilities([], supportsUris: true);
+    // Set the (global) encoder for the test process so the JSON we
+    // produce maps files to URIs so the test implementations can just work
+    // with the internal paths.
+    analyzer_plugin.clientUriConverter =
+        ClientUriConverter.withVirtualFileSupport(pathContext);
+  }
+
+  @override
+  tearDown() async {
+    // Reset the converter that some tests set up.
+    analyzer_plugin.clientUriConverter = ClientUriConverter.noop(pathContext);
+  }
+
+  Future test_macroModifiedContentEvent() async {
+    writeTestPackageConfig(macro: true);
+    // TODO(dantup): There are existing methods like
+    //  `ResourceProviderMixin.newAnalysisOptionsYamlFile` that would be useful
+    //  here, but we'd need to split it up to not be specific to
+    //  MemoryResourceProvider.
+    writeFile(
+      pathContext.join(testPackageRootPath, file_paths.analysisOptionsYaml),
+      AnalysisOptionsFileConfig(experiments: ['macros']).toContent(),
+    );
+    writeFile(
+      pathContext.join(testPackageRootPath, file_paths.pubspecYaml),
+      'name: test',
+    );
+
+    var macroImplementationFilePath =
+        pathContext.join(testPackageRootPath, 'lib', 'with_foo.dart');
+    writeFile(macroImplementationFilePath, withFooMethodMacro);
+
+    var content = '''
+import 'with_foo.dart';
+
+f() {
+  A().foo();
+}
+
+@WithFoo()
+class A {
+  void bar() {}
+}
+''';
+    writeFile(testFile, content);
+
+    await enableCustomUriSupport();
+    await standardAnalysisSetup();
+    await analysisFinished;
+
+    // Modify the macro and expect a change event.
+    writeFile(macroImplementationFilePath,
+        withFooMethodMacro.replaceAll('void foo() {', 'void foo2() {'));
+    await dartTextDocumentContentDidChangeNotifications
+        .firstWhere((notification) => notification.uri == testFileMacroUri);
+  }
+}
diff --git a/pkg/analysis_server/test/integration/lsp/test_all.dart b/pkg/analysis_server/test/integration/lsp/test_all.dart
index c2f7777ddeb..0dc5aaa446b 100644
--- a/pkg/analysis_server/test/integration/lsp/test_all.dart
+++ b/pkg/analysis_server/test/integration/lsp/test_all.dart
@@ -4,10 +4,12 @@
 
 import 'package:test_reflective_loader/test_reflective_loader.dart';
 
-import 'handle_test.dart' as handle_test;
+import 'handle_test.dart' as handle;
+import 'notification_test.dart' as notification;
 
 void main() {
   defineReflectiveSuite(() {
-    handle_test.main();
+    handle.main();
+    notification.main();
   }, name: 'lsp');
 }
diff --git a/pkg/analysis_server/test/integration/support/integration_test_methods.dart b/pkg/analysis_server/test/integration/support/integration_test_methods.dart
index d701c112394..1c46d128d4c 100644
--- a/pkg/analysis_server/test/integration/support/integration_test_methods.dart
+++ b/pkg/analysis_server/test/integration/support/integration_test_methods.dart
@@ -104,8 +104,25 @@ abstract class IntegrationTest {
   ///
   ///   - openUrlRequest
   ///   - showMessageRequest
-  Future sendServerSetClientCapabilities(List requests) async {
-    var params = ServerSetClientCapabilitiesParams(requests).toJson();
+  ///
+  /// supportsUris: bool (optional)
+  ///
+  ///   True if the client supports the server sending URIs in place of file
+  ///   paths.
+  ///
+  ///   In this mode, the server will use URIs in all protocol fields with the
+  ///   type FilePath. Returned URIs may be `file://` URIs or custom schemes.
+  ///   The client can fetch the file contents for URIs with custom schemes
+  ///   (and receive modification events) through the LSP protocol (see the
+  ///   "lsp" domain).
+  ///
+  ///   LSP notifications are automatically enabled when the client sets this
+  ///   capability.
+  Future sendServerSetClientCapabilities(List requests,
+      {bool? supportsUris}) async {
+    var params =
+        ServerSetClientCapabilitiesParams(requests, supportsUris: supportsUris)
+            .toJson();
     var result = await server.send('server.setClientCapabilities', params);
     outOfTestExpect(result, isNull);
   }
@@ -2565,6 +2582,20 @@ abstract class IntegrationTest {
     return LspHandleResult.fromJson(decoder, 'result', result);
   }
 
+  /// Reports an LSP notification from the server.
+  ///
+  /// Parameters
+  ///
+  /// lspNotification: object
+  ///
+  ///   The LSP NotificationMessage sent by the server.
+  late final Stream onLspNotification =
+      _onLspNotification.stream.asBroadcastStream();
+
+  /// Stream controller for [onLspNotification].
+  final _onLspNotification =
+      StreamController(sync: true);
+
   /// Dispatch the notification named [event], and containing parameters
   /// [params], to the appropriate stream.
   void dispatchNotification(String event, params) {
@@ -2650,6 +2681,10 @@ abstract class IntegrationTest {
         outOfTestExpect(params, isFlutterOutlineParams);
         _onFlutterOutline
             .add(FlutterOutlineParams.fromJson(decoder, 'params', params));
+      case 'lsp.notification':
+        outOfTestExpect(params, isLspNotificationParams);
+        _onLspNotification
+            .add(LspNotificationParams.fromJson(decoder, 'params', params));
       default:
         fail('Unexpected notification: $event');
     }
diff --git a/pkg/analysis_server/test/integration/support/protocol_matchers.dart b/pkg/analysis_server/test/integration/support/protocol_matchers.dart
index c9e4c1abf18..3d746e87297 100644
--- a/pkg/analysis_server/test/integration/support/protocol_matchers.dart
+++ b/pkg/analysis_server/test/integration/support/protocol_matchers.dart
@@ -2722,6 +2722,14 @@ final Matcher isLspHandleParams = LazyMatcher(
 final Matcher isLspHandleResult = LazyMatcher(
     () => MatchesJsonObject('lsp.handle result', {'lspResponse': isObject}));
 
+/// lsp.notification params
+///
+/// {
+///   "lspNotification": object
+/// }
+final Matcher isLspNotificationParams = LazyMatcher(() => MatchesJsonObject(
+    'lsp.notification params', {'lspNotification': isObject}));
+
 /// moveFile feedback
 final Matcher isMoveFileFeedback = isNull;
 
@@ -2956,10 +2964,12 @@ final Matcher isServerOpenUrlRequestResult = isNull;
 ///
 /// {
 ///   "requests": List
+///   "supportsUris": optional bool
 /// }
 final Matcher isServerSetClientCapabilitiesParams = LazyMatcher(() =>
-    MatchesJsonObject('server.setClientCapabilities params',
-        {'requests': isListOf(isString)}));
+    MatchesJsonObject(
+        'server.setClientCapabilities params', {'requests': isListOf(isString)},
+        optionalFields: {'supportsUris': isBool}));
 
 /// server.setClientCapabilities result
 final Matcher isServerSetClientCapabilitiesResult = isNull;
diff --git a/pkg/analysis_server/test/lsp/request_helpers_mixin.dart b/pkg/analysis_server/test/lsp/request_helpers_mixin.dart
index 8827de670ce..2cd3d105a35 100644
--- a/pkg/analysis_server/test/lsp/request_helpers_mixin.dart
+++ b/pkg/analysis_server/test/lsp/request_helpers_mixin.dart
@@ -107,6 +107,19 @@ mixin LspRequestHelpersMixin {
   /// Whether to include 'clientRequestTime' fields in outgoing messages.
   bool includeClientRequestTime = false;
 
+  /// A stream of [DartTextDocumentContentDidChangeParams] for any
+  /// `dart/textDocumentContentDidChange` notifications.
+  Stream
+      get dartTextDocumentContentDidChangeNotifications =>
+          notificationsFromServer
+              .where((notification) =>
+                  notification.method ==
+                  CustomMethods.dartTextDocumentContentDidChange)
+              .map((message) => DartTextDocumentContentDidChangeParams.fromJson(
+                  message.params as Map));
+
+  Stream get notificationsFromServer;
+
   Future?> callHierarchyIncoming(
       CallHierarchyItem item) {
     final request = makeRequest(
diff --git a/pkg/analysis_server/test/lsp/server_abstract.dart b/pkg/analysis_server/test/lsp/server_abstract.dart
index f9a78f88c19..974edc714f7 100644
--- a/pkg/analysis_server/test/lsp/server_abstract.dart
+++ b/pkg/analysis_server/test/lsp/server_abstract.dart
@@ -804,16 +804,6 @@ mixin LspAnalysisServerTestMixin
   /// list.
   final diagnostics = >{};
 
-  /// A stream of [OpenUriParams] for any `dart/openUri` notifications.
-  Stream
-      get dartTextDocumentContentDidChangeNotifications =>
-          notificationsFromServer
-              .where((notification) =>
-                  notification.method ==
-                  CustomMethods.dartTextDocumentContentDidChange)
-              .map((message) => DartTextDocumentContentDidChangeParams.fromJson(
-                  message.params as Map));
-
   /// A stream of [NotificationMessage]s from the server that may be errors.
   Stream get errorNotificationsFromServer {
     return notificationsFromServer.where(_isErrorNotification);
@@ -833,6 +823,7 @@ mixin LspAnalysisServerTestMixin
   Uri get mainFileMacroUri => mainFileUri.replace(scheme: macroClientUriScheme);
 
   /// A stream of [NotificationMessage]s from the server.
+  @override
   Stream get notificationsFromServer {
     return serverToClient
         .where((m) => m is NotificationMessage)
diff --git a/pkg/analysis_server/test/lsp_over_legacy/abstract_lsp_over_legacy.dart b/pkg/analysis_server/test/lsp_over_legacy/abstract_lsp_over_legacy.dart
index fccc0764b14..50e24bfef82 100644
--- a/pkg/analysis_server/test/lsp_over_legacy/abstract_lsp_over_legacy.dart
+++ b/pkg/analysis_server/test/lsp_over_legacy/abstract_lsp_over_legacy.dart
@@ -2,9 +2,11 @@
 // 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 'dart:async';
 import 'dart:convert';
 
 import 'package:analysis_server/lsp_protocol/protocol.dart';
+import 'package:analysis_server/protocol/protocol_constants.dart';
 import 'package:analysis_server/src/protocol/protocol_internal.dart';
 import 'package:analysis_server/src/protocol_server.dart';
 import 'package:analyzer_plugin/src/utilities/client_uri_converter.dart';
@@ -27,12 +29,25 @@ abstract class LspOverLegacyTest extends PubPackageAnalysisServerTest
   /// The last ID that was used for a legacy request.
   late String lastSentLegacyRequestId;
 
+  /// A controller for [notificationsFromServer].
+  final StreamController _notificationsFromServer =
+      StreamController.broadcast();
+
+  /// A stream of [NotificationMessage]s from the server.
+  @override
+  Stream get notificationsFromServer =>
+      _notificationsFromServer.stream;
+
   @override
   path.Context get pathContext => resourceProvider.pathContext;
 
   @override
   String get projectFolderPath => convertPath(testPackageRootPath);
 
+  /// The URI for the macro-generated content for [testFileUri].
+  Uri get testFileMacroUri =>
+      toUri(convertPath(testFilePath)).replace(scheme: macroClientUriScheme);
+
   Uri get testFileUri => toUri(convertPath(testFilePath));
 
   @override
@@ -109,6 +124,22 @@ abstract class LspOverLegacyTest extends PubPackageAnalysisServerTest
         .valueCount;
   }
 
+  @override
+  void processNotification(Notification notification) {
+    super.processNotification(notification);
+    if (notification.event == LSP_NOTIFICATION_NOTIFICATION) {
+      var params = LspNotificationParams.fromNotification(notification);
+      // Round-trip response via JSON because this doesn't happen automatically
+      // when we're bypassing the streams (running in-process) and we want to
+      // validate everything.
+      final lspNotificationJson = jsonDecode(jsonEncode(params.lspNotification))
+          as Map;
+      var lspNotificationMessage =
+          NotificationMessage.fromJson(lspNotificationJson);
+      _notificationsFromServer.add(lspNotificationMessage);
+    }
+  }
+
   @override
   Future setUp() async {
     super.setUp();
diff --git a/pkg/analysis_server/test/lsp_over_legacy/dart_text_document_content_provider_test.dart b/pkg/analysis_server/test/lsp_over_legacy/dart_text_document_content_provider_test.dart
new file mode 100644
index 00000000000..bca52196188
--- /dev/null
+++ b/pkg/analysis_server/test/lsp_over_legacy/dart_text_document_content_provider_test.dart
@@ -0,0 +1,104 @@
+// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file
+// 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/protocol/protocol_generated.dart';
+import 'package:analysis_server/src/lsp/test_macros.dart';
+import 'package:test/test.dart';
+import 'package:test_reflective_loader/test_reflective_loader.dart';
+
+import 'abstract_lsp_over_legacy.dart';
+
+void main() {
+  defineReflectiveSuite(() {
+    defineReflectiveTests(DartTextDocumentContentProviderTest);
+  });
+}
+
+@reflectiveTest
+class DartTextDocumentContentProviderTest extends LspOverLegacyTest
+    with TestMacros {
+  /// Tells the server we support custom URIs, otherwise we won't be allowed to
+  /// fetch any content from a URI.
+  Future enableCustomUriSupport() async {
+    var request = createLegacyRequest(
+        ServerSetClientCapabilitiesParams([], supportsUris: true));
+    await handleRequest(request);
+  }
+
+  Future test_valid_content() async {
+    writePackageConfig(projectFolderPath, macro: true);
+
+    newFile(
+        join(projectFolderPath, 'lib', 'with_foo.dart'), withFooMethodMacro);
+
+    var content = '''
+import 'with_foo.dart';
+
+f() {
+  A().foo();
+}
+
+@WithFoo()
+class A {
+  void bar() {}
+}
+''';
+    newFile(testFilePath, content);
+    await waitForTasksFinished();
+    await enableCustomUriSupport();
+
+    // Fetch the content for the custom URI scheme.
+    var macroGeneratedContent =
+        await getDartTextDocumentContent(testFileMacroUri);
+
+    // Verify the contents appear correct without doing an exact string
+    // check that might make this text fragile.
+    expect(
+      macroGeneratedContent!.content,
+      allOf([
+        contains('augment class A'),
+        contains('void foo() {'),
+      ]),
+    );
+  }
+
+  Future test_valid_eventAndModifiedContent() async {
+    writePackageConfig(projectFolderPath, macro: true);
+
+    var macroImplementationFilePath =
+        join(projectFolderPath, 'lib', 'with_foo.dart');
+    newFile(macroImplementationFilePath, withFooMethodMacro);
+
+    var content = '''
+import 'with_foo.dart';
+
+f() {
+  A().foo();
+}
+
+@WithFoo()
+class A {
+  void bar() {}
+}
+''';
+    newFile(testFilePath, content);
+    await waitForTasksFinished();
+    await enableCustomUriSupport();
+
+    // Verify initial contents of the macro.
+    var macroGeneratedContent =
+        await getDartTextDocumentContent(testFileMacroUri);
+    expect(macroGeneratedContent!.content, contains('void foo() {'));
+
+    // Modify the macro and expect a change event.
+    newFile(macroImplementationFilePath,
+        withFooMethodMacro.replaceAll('void foo() {', 'void foo2() {'));
+    await dartTextDocumentContentDidChangeNotifications
+        .firstWhere((notification) => notification.uri == testFileMacroUri);
+
+    // Verify updated contents of the macro.
+    macroGeneratedContent = await getDartTextDocumentContent(testFileMacroUri);
+    expect(macroGeneratedContent!.content, contains('void foo2() {'));
+  }
+}
diff --git a/pkg/analysis_server/test/lsp_over_legacy/test_all.dart b/pkg/analysis_server/test/lsp_over_legacy/test_all.dart
index 52461313c5a..488b610d88a 100644
--- a/pkg/analysis_server/test/lsp_over_legacy/test_all.dart
+++ b/pkg/analysis_server/test/lsp_over_legacy/test_all.dart
@@ -5,6 +5,8 @@
 import 'package:test_reflective_loader/test_reflective_loader.dart';
 
 import 'call_hierarchy_test.dart' as call_hierarchy;
+import 'dart_text_document_content_provider_test.dart'
+    as dart_text_document_content_provider;
 import 'document_color_test.dart' as document_color;
 import 'document_highlights_test.dart' as document_highlights;
 import 'document_symbols_test.dart' as document_symbols;
@@ -20,6 +22,7 @@ import 'workspace_symbols_test.dart' as workspace_symbols;
 void main() {
   defineReflectiveSuite(() {
     call_hierarchy.main();
+    dart_text_document_content_provider.main();
     document_color.main;
     document_highlights.main();
     document_symbols.main();
diff --git a/pkg/analysis_server/tool/spec/generated/java/AnalysisServer.java b/pkg/analysis_server/tool/spec/generated/java/AnalysisServer.java
index 738ea60864e..8aab58061e3 100644
--- a/pkg/analysis_server/tool/spec/generated/java/AnalysisServer.java
+++ b/pkg/analysis_server/tool/spec/generated/java/AnalysisServer.java
@@ -1010,8 +1010,14 @@ public interface AnalysisServer {
    *        requests that can be specified:
    *        - openUrlRequest
    *        - showMessageRequest
+   * @param supportsUris True if the client supports the server sending URIs in place of file paths.
+   *        In this mode, the server will use URIs in all protocol fields with the type FilePath.
+   *        Returned URIs may be `file://` URIs or custom schemes. The client can fetch the file
+   *        contents for URIs with custom schemes (and receive modification events) through the LSP
+   *        protocol (see the "lsp" domain). LSP notifications are automatically enabled when the
+   *        client sets this capability.
    */
-  public void server_setClientCapabilities(List requests);
+  public void server_setClientCapabilities(List requests, boolean supportsUris);
 
   /**
    * {@code server.setSubscriptions}
diff --git a/pkg/analysis_server/tool/spec/spec_input.html b/pkg/analysis_server/tool/spec/spec_input.html
index ad581c9657b..538192f7dd7 100644
--- a/pkg/analysis_server/tool/spec/spec_input.html
+++ b/pkg/analysis_server/tool/spec/spec_input.html
@@ -7,7 +7,7 @@
 
 

Analysis Server API Specification

Version - 1.35.0 + 1.36.0

This document contains a specification of the API provided by the @@ -142,6 +142,18 @@ ignoring the item or treating it with some default/fallback handling.

Changelog

+

1.36.0

+
    +
  • + Added a new supportsUris client capability to indicate that FilePaths should be URIs instead of file paths. +
  • +
  • + Added an experimental "lsp.handle" request to allow sending LSP requests through the protocol. +
  • +
  • + Added an experimental "lsp.notification" notification to allow receiving LSP notifications. +
  • +

1.35.0

  • @@ -393,6 +405,18 @@
+ + bool +

+ True if the client supports the server sending URIs in place of file paths. +

+

+ In this mode, the server will use URIs in all protocol fields with the type FilePath. Returned URIs may be `file://` URIs or custom schemes. The client can fetch the file contents for URIs with custom schemes (and receive modification events) through the LSP protocol (see the "lsp" domain). +

+

+ LSP notifications are automatically enabled when the client sets this capability. +

+
@@ -3415,6 +3439,19 @@ + +

+ Reports an LSP notification from the server. +

+ + +

+ The LSP NotificationMessage sent by the server. +

+ object +
+
+

Types

diff --git a/pkg/analysis_server_client/lib/handler/notification_handler.dart b/pkg/analysis_server_client/lib/handler/notification_handler.dart index 4fbdf27df82..eb2e78c5421 100644 --- a/pkg/analysis_server_client/lib/handler/notification_handler.dart +++ b/pkg/analysis_server_client/lib/handler/notification_handler.dart @@ -79,6 +79,10 @@ mixin NotificationHandler { onFlutterOutline( FlutterOutlineParams.fromJson(decoder, 'params', params)); break; + case LSP_NOTIFICATION_NOTIFICATION: + onLspNotification( + LspNotificationParams.fromJson(decoder, 'params', params)); + break; case SEARCH_NOTIFICATION_RESULTS: onSearchResults( SearchResultsParams.fromJson(decoder, 'params', params)); @@ -227,6 +231,9 @@ mixin NotificationHandler { /// request. void onFlutterOutline(FlutterOutlineParams params) {} + /// Reports an LSP notification from the server. + void onLspNotification(LspNotificationParams params) {} + /// Reports some or all of the results of performing a requested /// search. Unlike other notifications, this notification /// contains search results that should be added to any diff --git a/pkg/analysis_server_client/lib/src/protocol/protocol_constants.dart b/pkg/analysis_server_client/lib/src/protocol/protocol_constants.dart index e15a9142ce3..b27fb1ef191 100644 --- a/pkg/analysis_server_client/lib/src/protocol/protocol_constants.dart +++ b/pkg/analysis_server_client/lib/src/protocol/protocol_constants.dart @@ -6,7 +6,7 @@ // To regenerate the file, use the script // "pkg/analysis_server/tool/spec/generate_files". -const String PROTOCOL_VERSION = '1.35.0'; +const String PROTOCOL_VERSION = '1.36.0'; const String ANALYSIS_NOTIFICATION_ANALYZED_FILES = 'analysis.analyzedFiles'; const String ANALYSIS_NOTIFICATION_ANALYZED_FILES_DIRECTORIES = 'directories'; @@ -273,6 +273,8 @@ const String FLUTTER_REQUEST_SET_WIDGET_PROPERTY_VALUE_ID = 'id'; const String FLUTTER_REQUEST_SET_WIDGET_PROPERTY_VALUE_VALUE = 'value'; const String FLUTTER_RESPONSE_GET_WIDGET_DESCRIPTION_PROPERTIES = 'properties'; const String FLUTTER_RESPONSE_SET_WIDGET_PROPERTY_VALUE_CHANGE = 'change'; +const String LSP_NOTIFICATION_NOTIFICATION = 'lsp.notification'; +const String LSP_NOTIFICATION_NOTIFICATION_LSP_NOTIFICATION = 'lspNotification'; const String LSP_REQUEST_HANDLE = 'lsp.handle'; const String LSP_REQUEST_HANDLE_LSP_MESSAGE = 'lspMessage'; const String LSP_RESPONSE_HANDLE_LSP_RESPONSE = 'lspResponse'; @@ -334,6 +336,8 @@ const String SERVER_REQUEST_OPEN_URL_REQUEST_URL = 'url'; const String SERVER_REQUEST_SET_CLIENT_CAPABILITIES = 'server.setClientCapabilities'; const String SERVER_REQUEST_SET_CLIENT_CAPABILITIES_REQUESTS = 'requests'; +const String SERVER_REQUEST_SET_CLIENT_CAPABILITIES_SUPPORTS_URIS = + 'supportsUris'; const String SERVER_REQUEST_SET_SUBSCRIPTIONS = 'server.setSubscriptions'; const String SERVER_REQUEST_SET_SUBSCRIPTIONS_SUBSCRIPTIONS = 'subscriptions'; const String SERVER_REQUEST_SHOW_MESSAGE_REQUEST = 'server.showMessageRequest'; diff --git a/pkg/analysis_server_client/lib/src/protocol/protocol_generated.dart b/pkg/analysis_server_client/lib/src/protocol/protocol_generated.dart index e894e513c3b..badfb1b3d5d 100644 --- a/pkg/analysis_server_client/lib/src/protocol/protocol_generated.dart +++ b/pkg/analysis_server_client/lib/src/protocol/protocol_generated.dart @@ -11889,6 +11889,66 @@ class LspHandleResult implements ResponseResult { int get hashCode => lspResponse.hashCode; } +/// lsp.notification params +/// +/// { +/// "lspNotification": object +/// } +/// +/// Clients may not extend, implement or mix-in this class. +class LspNotificationParams implements HasToJson { + /// The LSP NotificationMessage sent by the server. + Object lspNotification; + + LspNotificationParams(this.lspNotification); + + factory LspNotificationParams.fromJson( + JsonDecoder jsonDecoder, String jsonPath, Object? json) { + json ??= {}; + if (json is Map) { + Object lspNotification; + if (json.containsKey('lspNotification')) { + lspNotification = json['lspNotification'] as Object; + } else { + throw jsonDecoder.mismatch(jsonPath, 'lspNotification'); + } + return LspNotificationParams(lspNotification); + } else { + throw jsonDecoder.mismatch(jsonPath, 'lsp.notification params', json); + } + } + + factory LspNotificationParams.fromNotification(Notification notification) { + return LspNotificationParams.fromJson( + ResponseDecoder(null), 'params', notification.params); + } + + @override + Map toJson() { + var result = {}; + result['lspNotification'] = lspNotification; + return result; + } + + Notification toNotification() { + return Notification('lsp.notification', toJson()); + } + + @override + String toString() => json.encode(toJson()); + + @override + bool operator ==(other) { + if (other is LspNotificationParams) { + return lspNotification == other.lspNotification; + } + return false; + } + + @override + int get hashCode => lspNotification.hashCode; +} + /// MessageAction /// /// { @@ -15335,6 +15395,7 @@ class ServerService implements Enum { /// /// { /// "requests": List +/// "supportsUris": optional bool /// } /// /// Clients may not extend, implement or mix-in this class. @@ -15354,7 +15415,20 @@ class ServerSetClientCapabilitiesParams implements RequestParams { /// - showMessageRequest List requests; - ServerSetClientCapabilitiesParams(this.requests); + /// True if the client supports the server sending URIs in place of file + /// paths. + /// + /// In this mode, the server will use URIs in all protocol fields with the + /// type FilePath. Returned URIs may be `file://` URIs or custom schemes. The + /// client can fetch the file contents for URIs with custom schemes (and + /// receive modification events) through the LSP protocol (see the "lsp" + /// domain). + /// + /// LSP notifications are automatically enabled when the client sets this + /// capability. + bool? supportsUris; + + ServerSetClientCapabilitiesParams(this.requests, {this.supportsUris}); factory ServerSetClientCapabilitiesParams.fromJson( JsonDecoder jsonDecoder, String jsonPath, Object? json) { @@ -15367,7 +15441,13 @@ class ServerSetClientCapabilitiesParams implements RequestParams { } else { throw jsonDecoder.mismatch(jsonPath, 'requests'); } - return ServerSetClientCapabilitiesParams(requests); + bool? supportsUris; + if (json.containsKey('supportsUris')) { + supportsUris = jsonDecoder.decodeBool( + '$jsonPath.supportsUris', json['supportsUris']); + } + return ServerSetClientCapabilitiesParams(requests, + supportsUris: supportsUris); } else { throw jsonDecoder.mismatch( jsonPath, 'server.setClientCapabilities params', json); @@ -15383,6 +15463,10 @@ class ServerSetClientCapabilitiesParams implements RequestParams { Map toJson() { var result = {}; result['requests'] = requests; + var supportsUris = this.supportsUris; + if (supportsUris != null) { + result['supportsUris'] = supportsUris; + } return result; } @@ -15398,13 +15482,17 @@ class ServerSetClientCapabilitiesParams implements RequestParams { bool operator ==(other) { if (other is ServerSetClientCapabilitiesParams) { return listEqual( - requests, other.requests, (String a, String b) => a == b); + requests, other.requests, (String a, String b) => a == b) && + supportsUris == other.supportsUris; } return false; } @override - int get hashCode => Object.hashAll(requests); + int get hashCode => Object.hash( + Object.hashAll(requests), + supportsUris, + ); } /// server.setClientCapabilities result diff --git a/pkg/analyzer_plugin/lib/src/utilities/client_uri_converter.dart b/pkg/analyzer_plugin/lib/src/utilities/client_uri_converter.dart index 4260e23ff7e..c075b205028 100644 --- a/pkg/analyzer_plugin/lib/src/utilities/client_uri_converter.dart +++ b/pkg/analyzer_plugin/lib/src/utilities/client_uri_converter.dart @@ -95,14 +95,16 @@ class _VirtualFileClientUriConverter extends ClientUriConverter { // For URIs with no scheme, assume it was a relative path and provide a // better message than "scheme '' is not supported". if (uri.scheme.isEmpty) { - throw ArgumentError.value(uri, 'uri', 'URI is not a valid file:// URI'); + throw ArgumentError.value( + uri.toString(), 'uri', 'URI is not a valid file:// URI'); } if (!supportedSchemes.contains(uri.scheme)) { - var supportedSchemesString = - supportedSchemes.map((scheme) => "'$scheme'").join(', '); + var supportedSchemesString = supportedSchemes.isEmpty + ? '(none)' + : supportedSchemes.map((scheme) => "'$scheme'").join(', '); throw ArgumentError.value( - uri, + uri.toString(), 'uri', "URI scheme '${uri.scheme}' is not supported. " 'Allowed schemes are $supportedSchemesString.',