[analysis_server] Add experimental TextDocumentContentProvider support for virtual documents

This adds support for serving the contents of virtual files to the client over a custom LSP protocol (based on the VS Code API of the same name). This is currently a custom Dart protocol but hopefully very close to something that could become standard LSP in future.

If the client advertises support for this feature (currently with an experimental flag "supportsDartTextDocumentContentProviderEXP1") we will return the set of URI schemes we can provide content for (currently "dart-macro-file"). Additionally, we will map internal analyzer macro paths (like `/foo/bar.macro.dart`) onto that scheme (`dart-macro-file://foo/bar.dart`) instead of standard `file://` URIs.

Overlays are not created for these kinds of files (because they would override the server-generated content).

Some language functionality "just works" because we can get resolved ASTs for the macro files (and many LSP features operate on these), but more testing (and tests) are required.

Included are tests for the virtual file methods (and events) and Go-to-Definition. Tests for other features are outstanding.

Change-Id: I2056699652873a12b730f565b823f187f883a1ee
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/345420
Reviewed-by: Brian Wilkerson <brianwilkerson@google.com>
Commit-Queue: Brian Wilkerson <brianwilkerson@google.com>
Reviewed-by: Konstantin Shcheglov <scheglov@google.com>
This commit is contained in:
Danny Tuppeny 2024-01-18 17:34:43 +00:00 committed by Commit Queue
parent 0c3503dde7
commit 2b713a9e4e
34 changed files with 1017 additions and 107 deletions

View file

@ -94,16 +94,6 @@ abstract class AnalysisServer {
/// A flag indicating whether plugins are supported in this build.
static final bool supportsPlugins = true;
/// The full set of URI schemes that the server can support.
///
/// Which schemes are valid for a given server invocation may depend on the
/// clients capabilities so being present in this set does not necessarily
/// mean the scheme is valid to send to the client.
///
/// The [uriConverter] handles mapping of internal analyzer file
/// paths/references to URIs and back.
static const supportedUriSchemes = {'file'};
/// The options of this server instance.
AnalysisServerOptions options;
@ -871,6 +861,7 @@ abstract class AnalysisServer {
pubPackageService.shutdown();
surveyManager?.shutdown();
await contextManager.dispose();
await analyticsManager.shutdown();
}

View file

@ -73,6 +73,9 @@ abstract class ContextManager {
/// Returns owners of files.
OwnedFiles get ownedFiles;
/// Disposes and cleans up any analysis contexts.
Future<void> dispose();
/// Return the existing analysis context that should be used to analyze the
/// given [path], or `null` if the [path] is not analyzed in any of the
/// created analysis contexts.
@ -285,6 +288,11 @@ class ContextManagerImpl implements ContextManager {
return _collection?.ownedFiles ?? OwnedFiles();
}
@override
Future<void> dispose() async {
await _destroyAnalysisContexts();
}
@override
DriverBasedAnalysisContext? getContextFor(String path) {
try {
@ -727,6 +735,7 @@ class ContextManagerImpl implements ContextManager {
watcherSubscriptions.clear();
final collection = _collection;
_collection = null;
if (collection != null) {
for (final analysisContext in collection.contexts) {
_destroyAnalysisContext(analysisContext);

View file

@ -4,6 +4,22 @@
import 'package:analysis_server/lsp_protocol/protocol.dart';
/// The key in the client capabilities experimental object that enables the Dart
/// TextDocumentContentProvider.
///
/// The presence of this key indicates that the client supports our
/// (non-standard) way of using TextDocumentContentProvider. This will need to
/// continue to be supported after switching to standard LSP support for some
/// period to support outdated extensions.
///
/// This is current EXPERIMENTAL and has a suffix that will allow opting-in for
/// dev/testing only if the Dart-Code and server versions match. This number
/// will be increased if breaking changes to the API are made. The suffix should
/// be removed here (and in Dart-Code) when work is complete and support is
/// enabled by default in Dart-Code.
const dartExperimentalTextDocumentContentProviderKey =
'supportsDartTextDocumentContentProviderEXP1';
/// Wraps the client (editor) capabilities to improve performance.
///
/// Sets transferred as arrays in JSON will be converted to Sets for faster
@ -88,6 +104,7 @@ class LspClientCapabilities {
final bool experimentalSnippetTextEdit;
final Set<String> codeActionCommandParameterSupportedKinds;
final bool supportsShowMessageRequest;
final bool supportsDartExperimentalTextDocumentContentProvider;
factory LspClientCapabilities(ClientCapabilities raw) {
final workspace = raw.workspace;
@ -162,6 +179,8 @@ class LspClientCapabilities {
final commandParameterSupportedKinds =
_listToSet(commandParameterSupport['supportedKinds'] as List?)
.cast<String>();
final supportsDartExperimentalTextDocumentContentProvider =
experimental[dartExperimentalTextDocumentContentProviderKey] != null;
/// At the time of writing (2023-02-01) there is no official capability for
/// supporting 'showMessageRequest' because LSP assumed all clients
@ -208,6 +227,8 @@ class LspClientCapabilities {
experimentalSnippetTextEdit: experimentalSnippetTextEdit,
codeActionCommandParameterSupportedKinds: commandParameterSupportedKinds,
supportsShowMessageRequest: supportsShowMessageRequest,
supportsDartExperimentalTextDocumentContentProvider:
supportsDartExperimentalTextDocumentContentProvider,
);
}
@ -245,6 +266,7 @@ class LspClientCapabilities {
required this.experimentalSnippetTextEdit,
required this.codeActionCommandParameterSupportedKinds,
required this.supportsShowMessageRequest,
required this.supportsDartExperimentalTextDocumentContentProvider,
});
/// Converts a list to a `Set`, returning null if the list is null.

View file

@ -3,7 +3,6 @@
// BSD-style license that can be found in the LICENSE file.
import 'package:analysis_server/lsp_protocol/protocol.dart';
import 'package:analysis_server/src/analysis_server.dart';
import 'package:analysis_server/src/services/refactoring/framework/refactoring_processor.dart';
/// The characters that will cause the editor to automatically commit the selected
@ -53,12 +52,6 @@ final analysisOptionsFile = TextDocumentFilterWithScheme(
/// A [ProgressToken] used for reporting progress while the server is analyzing.
final analyzingProgressToken = ProgressToken.t2('ANALYZING');
/// A [TextDocumentFilterWithScheme] for Dart file.
final dartFiles = [
for (final scheme in AnalysisServer.supportedUriSchemes)
TextDocumentFilterWithScheme(language: 'dart', scheme: scheme)
];
final emptyWorkspaceEdit = WorkspaceEdit();
final fileOperationRegistrationOptions = FileOperationRegistrationOptions(
@ -125,6 +118,9 @@ abstract class CustomMethods {
static const publishFlutterOutline =
Method('dart/textDocument/publishFlutterOutline');
static const super_ = Method('dart/textDocument/super');
static const dartTextDocumentContent = Method('dart/textDocumentContent');
static const dartTextDocumentContentDidChange =
Method('dart/textDocumentContentDidChange');
// TODO(dantup): Remove custom AnalyzerStatus status method soon as no clients
// should be relying on it as we now support proper $/progress events.

View file

@ -7,7 +7,6 @@ import 'dart:async';
import 'package:analysis_server/src/analysis_server.dart';
import 'package:analysis_server/src/computer/computer_call_hierarchy.dart'
as call_hierarchy;
import 'package:analysis_server/src/lsp/constants.dart';
import 'package:analysis_server/src/lsp/handlers/handlers.dart';
import 'package:analysis_server/src/lsp/mapping.dart';
import 'package:analysis_server/src/lsp/registration/feature_registration.dart';

View file

@ -0,0 +1,72 @@
// 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/lsp_protocol/protocol.dart';
import 'package:analysis_server/src/lsp/constants.dart';
import 'package:analysis_server/src/lsp/handlers/handlers.dart';
import 'package:analysis_server/src/lsp/registration/feature_registration.dart';
typedef StaticOptions = DartTextDocumentContentProviderRegistrationOptions?;
class DartTextDocumentContentProviderHandler extends SharedMessageHandler<
DartTextDocumentContentParams, DartTextDocumentContent> {
DartTextDocumentContentProviderHandler(super.server);
@override
Method get handlesMessage => CustomMethods.dartTextDocumentContent;
@override
LspJsonHandler<DartTextDocumentContentParams> get jsonHandler =>
DartTextDocumentContentParams.jsonHandler;
@override
Future<ErrorOr<DartTextDocumentContent>> handle(
DartTextDocumentContentParams params,
MessageInfo message,
CancellationToken token) async {
var allowedSchemes = server.uriConverter.supportedNonFileSchemes;
var uri = params.uri;
if (!allowedSchemes.contains(uri.scheme)) {
return error(
ErrorCodes.InvalidParams,
"Fetching content for scheme '${uri.scheme}' is not supported. "
'Supported schemes are '
'${allowedSchemes.map((scheme) => "'$scheme'").join(', ')}.',
);
}
return pathOfUri(uri).mapResult((filePath) async {
var result = await server.getResolvedUnit(filePath);
var content = result?.content;
// TODO(dantup): Switch to this once implemented to avoid resolved result.
// var file = server.getAnalysisDriver(filePath)?.getFileSync(filePath);
// var content = file is FileResult ? file.file.readAsStringSync() : null;
return success(DartTextDocumentContent(content: content));
});
}
}
class DartTextDocumentContentProviderRegistrations extends FeatureRegistration
with SingleDynamicRegistration, StaticRegistration<StaticOptions> {
@override
final DartTextDocumentContentProviderRegistrationOptions options;
DartTextDocumentContentProviderRegistrations(super.info)
: options = DartTextDocumentContentProviderRegistrationOptions(
schemes: info.customDartSchemes.toList());
@override
Method get registrationMethod => CustomMethods.dartTextDocumentContent;
@override
StaticOptions get staticOptions => options;
@override
bool get supportsDynamic => false;
@override
bool get supportsStatic => true;
}

View file

@ -5,7 +5,6 @@
import 'package:analysis_server/lsp_protocol/protocol.dart';
import 'package:analysis_server/src/computer/computer_color.dart'
show ColorComputer, ColorReference;
import 'package:analysis_server/src/lsp/constants.dart';
import 'package:analysis_server/src/lsp/handlers/handlers.dart';
import 'package:analysis_server/src/lsp/mapping.dart';
import 'package:analysis_server/src/lsp/registration/feature_registration.dart';

View file

@ -3,7 +3,6 @@
// BSD-style license that can be found in the LICENSE file.
import 'package:analysis_server/lsp_protocol/protocol.dart' hide Element;
import 'package:analysis_server/src/lsp/constants.dart';
import 'package:analysis_server/src/lsp/handlers/handlers.dart';
import 'package:analysis_server/src/lsp/mapping.dart';
import 'package:analysis_server/src/lsp/registration/feature_registration.dart';

View file

@ -4,7 +4,6 @@
import 'package:analysis_server/lsp_protocol/protocol.dart';
import 'package:analysis_server/src/computer/computer_inlay_hint.dart';
import 'package:analysis_server/src/lsp/constants.dart';
import 'package:analysis_server/src/lsp/handlers/handlers.dart';
import 'package:analysis_server/src/lsp/mapping.dart';
import 'package:analysis_server/src/lsp/registration/feature_registration.dart';

View file

@ -5,7 +5,6 @@
import 'package:analysis_server/lsp_protocol/protocol.dart';
import 'package:analysis_server/src/computer/computer_selection_ranges.dart'
hide SelectionRange;
import 'package:analysis_server/src/lsp/constants.dart';
import 'package:analysis_server/src/lsp/handlers/handlers.dart';
import 'package:analysis_server/src/lsp/mapping.dart';
import 'package:analysis_server/src/lsp/registration/feature_registration.dart';

View file

@ -15,6 +15,7 @@ import 'package:analysis_server/src/lsp/handlers/handler_change_workspace_folder
import 'package:analysis_server/src/lsp/handlers/handler_code_actions.dart';
import 'package:analysis_server/src/lsp/handlers/handler_completion.dart';
import 'package:analysis_server/src/lsp/handlers/handler_completion_resolve.dart';
import 'package:analysis_server/src/lsp/handlers/handler_dart_text_document_content_provider.dart';
import 'package:analysis_server/src/lsp/handlers/handler_definition.dart';
import 'package:analysis_server/src/lsp/handlers/handler_document_color.dart';
import 'package:analysis_server/src/lsp/handlers/handler_document_color_presentation.dart';
@ -113,6 +114,7 @@ class InitializedStateMessageHandler extends ServerStateMessageHandler {
/// Generators for handlers that work with any [AnalysisServer].
static const sharedHandlerGenerators =
<_RequestHandlerGenerator<AnalysisServer>>[
DartTextDocumentContentProviderHandler.new,
DocumentColorHandler.new,
DocumentColorPresentationHandler.new,
DocumentHighlightsHandler.new,

View file

@ -25,7 +25,14 @@ class TextDocumentChangeHandler
@override
FutureOr<ErrorOr<void>> handle(DidChangeTextDocumentParams params,
MessageInfo message, CancellationToken token) {
final path = pathOfDoc(params.textDocument);
final doc = params.textDocument;
// Editors should never try to change our macro files, but just in case
// we get these requests, ignore them.
if (!isEditableDocument(doc.uri)) {
return success(null);
}
final path = pathOfDoc(doc);
return path.mapResult((path) => _changeFile(path, params));
}
@ -69,13 +76,17 @@ class TextDocumentCloseHandler
@override
FutureOr<ErrorOr<void>> handle(DidCloseTextDocumentParams params,
MessageInfo message, CancellationToken token) {
final path = pathOfDoc(params.textDocument);
final doc = params.textDocument;
final path = pathOfDoc(doc);
return path.mapResult((path) async {
// It's critical overlays are processed synchronously because other
// requests that sneak in when we `await` rely on them being
// correct.
server.onOverlayDestroyed(path);
server.documentVersions.remove(path);
if (isEditableDocument(doc.uri)) {
// It's critical overlays are processed synchronously because other
// requests that sneak in when we `await` rely on them being
// correct.
server.onOverlayDestroyed(path);
server.documentVersions.remove(path);
}
// This is async because if onlyAnalyzeProjectsWithOpenFiles is true
// it can trigger a change of analysis roots.
await server.removePriorityFile(path);
@ -102,16 +113,18 @@ class TextDocumentOpenHandler
final doc = params.textDocument;
final path = pathOfDocItem(doc);
return path.mapResult((path) async {
// We don't get a OptionalVersionedTextDocumentIdentifier with a didOpen but we
// do get the necessary info to create one.
server.documentVersions[path] = VersionedTextDocumentIdentifier(
version: params.textDocument.version,
uri: params.textDocument.uri,
);
// It's critical overlays are processed synchronously because other
// requests that sneak in when we `await` rely on them being
// correct.
server.onOverlayCreated(path, doc.text);
if (isEditableDocument(doc.uri)) {
// We don't get a OptionalVersionedTextDocumentIdentifier with a didOpen but we
// do get the necessary info to create one.
server.documentVersions[path] = VersionedTextDocumentIdentifier(
version: params.textDocument.version,
uri: params.textDocument.uri,
);
// It's critical overlays are processed synchronously because other
// requests that sneak in when we `await` rely on them being
// correct.
server.onOverlayCreated(path, doc.text);
}
// This is async because if onlyAnalyzeProjectsWithOpenFiles is true
// it can trigger a change of analysis roots.

View file

@ -3,7 +3,6 @@
// BSD-style license that can be found in the LICENSE file.
import 'package:analysis_server/lsp_protocol/protocol.dart';
import 'package:analysis_server/src/lsp/constants.dart';
import 'package:analysis_server/src/lsp/handlers/handlers.dart';
import 'package:analysis_server/src/lsp/mapping.dart';
import 'package:analysis_server/src/lsp/registration/feature_registration.dart';

View file

@ -8,7 +8,6 @@ import 'package:analysis_server/lsp_protocol/protocol.dart';
import 'package:analysis_server/src/analysis_server.dart';
import 'package:analysis_server/src/computer/computer_lazy_type_hierarchy.dart'
as type_hierarchy;
import 'package:analysis_server/src/lsp/constants.dart';
import 'package:analysis_server/src/lsp/handlers/handlers.dart';
import 'package:analysis_server/src/lsp/mapping.dart';
import 'package:analysis_server/src/lsp/registration/feature_registration.dart';

View file

@ -85,13 +85,24 @@ mixin HandlerHelperMixin<S extends AnalysisServer> {
ErrorOr<T> fileNotAnalyzedError<T>(String path) => error<T>(
ServerErrorCodes.FileNotAnalyzed, 'File is not being analyzed', path);
/// Returns the file system path for a TextDocumentIdentifier.
/// Returns whether [doc] is a user-editable document or not.
///
/// Only editable documents have overlays and can be modified by the client.
bool isEditableDocument(Uri uri) {
// Currently, only file:// URIs are editable documents.
return uri.isScheme('file');
}
/// Returns the file system path (or internal analyzer file reference) for a
/// TextDocumentIdentifier.
ErrorOr<String> pathOfDoc(TextDocumentIdentifier doc) => pathOfUri(doc.uri);
/// Returns the file system path for a TextDocumentItem.
/// Returns the file system path (or internal analyzer file reference) for a
/// TextDocumentItem.
ErrorOr<String> pathOfDocItem(TextDocumentItem doc) => pathOfUri(doc.uri);
/// Returns the file system path for a file URI.
/// Returns the file system path (or internal analyzer file reference) for a
/// file URI.
ErrorOr<String> pathOfUri(Uri? uri) {
if (uri == null) {
return ErrorOr<String>.error(ResponseError(
@ -99,40 +110,60 @@ mixin HandlerHelperMixin<S extends AnalysisServer> {
message: 'Document URI was not supplied',
));
}
final isValidFileUri = uri.isScheme('file');
if (!isValidFileUri) {
// 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) {
return ErrorOr<String>.error(ResponseError(
code: ServerErrorCodes.InvalidFilePath,
message: 'URI was not a valid file:// URI',
message: 'URI is not a valid file:// URI',
data: uri.toString(),
));
}
var supportedSchemes = server.uriConverter.supportedSchemes;
var isValidScheme = supportedSchemes.contains(uri.scheme);
if (!isValidScheme) {
var supportedSchemesString =
supportedSchemes.map((scheme) => "'$scheme'").join(', ');
return ErrorOr<String>.error(ResponseError(
code: ServerErrorCodes.InvalidFilePath,
message: "URI scheme '${uri.scheme}' is not supported. "
'Allowed schemes are $supportedSchemesString.',
data: uri.toString(),
));
}
try {
final context = server.resourceProvider.pathContext;
final isWindows = context.style == path.Style.windows;
var context = server.resourceProvider.pathContext;
var isWindows = context.style == path.Style.windows;
// Use toFilePath() here and not context.fromUri() because they're not
// quite the same. `toFilePath()` will throw for some kinds of invalid
// file URIs (such as those with fragments) that context.fromUri() does
// not. We want the stricter handling here.
final filePath = uri.toFilePath(windows: isWindows);
// not. We want to validate using the stricter handling.
var filePath = uri
.replace(scheme: 'file') // We can only use toFilePath() with file://
.toFilePath(windows: isWindows);
// On Windows, paths that start with \ and not a drive letter are not
// supported but will return `true` from `path.isAbsolute` so check for them
// specifically.
if (isWindows && filePath.startsWith(r'\')) {
return ErrorOr<String>.error(ResponseError(
code: ServerErrorCodes.InvalidFilePath,
message: 'URI was not an absolute file path (missing drive letter)',
message: 'URI does not contain an absolute file path '
'(missing drive letter)',
data: uri.toString(),
));
}
return ErrorOr<String>.success(filePath);
// Use the proper converter for the return value.
return ErrorOr<String>.success(uriConverter.fromClientUri(uri));
} catch (e) {
// Even if tryParse() works and file == scheme, fromUri() can throw on
// Windows if there are invalid characters.
return ErrorOr<String>.error(ResponseError(
code: ServerErrorCodes.InvalidFilePath,
message: 'File URI did not contain a valid file path',
message: 'URI does not contain a valid file path',
data: uri.toString()));
}
}

View file

@ -29,6 +29,7 @@ import 'package:analysis_server/src/server/diagnostic_server.dart';
import 'package:analysis_server/src/server/error_notifier.dart';
import 'package:analysis_server/src/server/performance.dart';
import 'package:analysis_server/src/services/user_prompts/dart_fix_prompt_manager.dart';
import 'package:analysis_server/src/utilities/client_uri_converter.dart';
import 'package:analysis_server/src/utilities/flutter.dart';
import 'package:analysis_server/src/utilities/process.dart';
import 'package:analyzer/dart/analysis/context_locator.dart';
@ -381,6 +382,14 @@ class LspAnalysisServer extends AnalysisServer {
_clientInfo = clientInfo;
_initializationOptions = LspInitializationOptions(initializationOptions);
/// Enable virtual file support.
var supportsVirtualFiles = _clientCapabilities
?.supportsDartExperimentalTextDocumentContentProvider ??
false;
if (supportsVirtualFiles) {
uriConverter = ClientUriConverter.withVirtualFileSupport(pathContext);
}
performanceAfterStartup = ServerPerformance();
performance = performanceAfterStartup!;
@ -890,6 +899,7 @@ class LspAnalysisServer extends AnalysisServer {
channel.close();
}));
unawaited(_pluginChangeSubscription?.cancel());
_pluginChangeSubscription = null;
}
/// There was an error related to the socket from which messages are being
@ -1156,6 +1166,24 @@ class LspServerContextManagerCallbacks
if (analysisServer.suppressAnalysisResults) {
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);
}

View file

@ -5,11 +5,11 @@
import 'package:analysis_server/lsp_protocol/protocol.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';
import 'package:analysis_server/src/lsp/handlers/handler_call_hierarchy.dart';
import 'package:analysis_server/src/lsp/handlers/handler_change_workspace_folders.dart';
import 'package:analysis_server/src/lsp/handlers/handler_code_actions.dart';
import 'package:analysis_server/src/lsp/handlers/handler_completion.dart';
import 'package:analysis_server/src/lsp/handlers/handler_dart_text_document_content_provider.dart';
import 'package:analysis_server/src/lsp/handlers/handler_definition.dart';
import 'package:analysis_server/src/lsp/handlers/handler_document_color.dart';
import 'package:analysis_server/src/lsp/handlers/handler_document_highlights.dart';
@ -55,6 +55,9 @@ abstract class FeatureRegistration {
/// for. This information is derived from the [ClientCapabilities].
ClientDynamicRegistrations get clientDynamic => _context.clientDynamic;
/// A set of filters for the currently supported Dart files.
List<TextDocumentFilterWithScheme> get dartFiles => _context.dartFilters;
/// Gets all dynamic registrations for this feature.
///
/// These registrations should only be used if [supportsDynamic] returns true.
@ -110,6 +113,8 @@ class LspFeatures {
final WorkspaceDidChangeConfigurationRegistrations
workspaceDidChangeConfiguration;
final WorkspaceSymbolRegistrations workspaceSymbol;
final DartTextDocumentContentProviderRegistrations
dartTextDocumentContentProvider;
LspFeatures(RegistrationContext context)
: callHierarchy = CallHierarchyRegistrations(context),
@ -140,7 +145,9 @@ class LspFeatures {
willRename = WillRenameFilesRegistrations(context),
workspaceDidChangeConfiguration =
WorkspaceDidChangeConfigurationRegistrations(context),
workspaceSymbol = WorkspaceSymbolRegistrations(context);
workspaceSymbol = WorkspaceSymbolRegistrations(context),
dartTextDocumentContentProvider =
DartTextDocumentContentProviderRegistrations(context);
List<FeatureRegistration> get allFeatures => [
callHierarchy,
@ -188,9 +195,19 @@ class RegistrationContext {
/// The configuration provided by the client.
final LspClientConfiguration clientConfiguration;
/// Filters for all Dart files supported by the current server.
final List<TextDocumentFilterWithScheme> dartFilters;
/// Custom schemes supported for Dart files by the current server.
///
/// 'file' is implied and not included.
final Set<String> customDartSchemes;
RegistrationContext({
required this.clientCapabilities,
required this.clientConfiguration,
required this.customDartSchemes,
required this.dartFilters,
required this.pluginTypes,
}) : clientDynamic = ClientDynamicRegistrations(clientCapabilities.raw);
}

View file

@ -138,6 +138,7 @@ class ServerCapabilitiesComputer {
/// List of current registrations.
Set<Registration> currentRegistrations = {};
var _lastRegistrationId = 0;
ServerCapabilitiesComputer(this._server);
@ -159,12 +160,8 @@ class ServerCapabilitiesComputer {
ServerCapabilities computeServerCapabilities(
LspClientCapabilities clientCapabilities,
) {
final context = RegistrationContext(
clientCapabilities: clientCapabilities,
clientConfiguration: _server.lspClientConfiguration,
pluginTypes: pluginTypes,
);
final features = LspFeatures(context);
var context = _createRegistrationContext();
var features = LspFeatures(context);
return ServerCapabilities(
textDocumentSync: features.textDocumentSync.staticRegistration,
@ -204,6 +201,13 @@ class ServerCapabilitiesComputer {
)
: null,
),
experimental: clientCapabilities
.supportsDartExperimentalTextDocumentContentProvider
? {
'dartTextDocumentContentProvider':
features.dartTextDocumentContentProvider.staticRegistration,
}
: null,
);
}
@ -215,12 +219,7 @@ class ServerCapabilitiesComputer {
/// support and it will be up to them to decide which file types they will
/// send requests for.
Future<void> performDynamicRegistration() async {
final context = RegistrationContext(
clientCapabilities: _server.lspClientCapabilities!,
clientConfiguration: _server.lspClientConfiguration,
pluginTypes: pluginTypes,
);
final features = LspFeatures(context);
final features = LspFeatures(_createRegistrationContext());
final registrations = <Registration>[];
// Collect dynamic registrations for all features.
@ -311,4 +310,14 @@ class ServerCapabilitiesComputer {
await unregistrationRequest;
await registrationRequest;
}
RegistrationContext _createRegistrationContext() {
return RegistrationContext(
clientCapabilities: _server.lspClientCapabilities!,
clientConfiguration: _server.lspClientConfiguration,
customDartSchemes: _server.uriConverter.supportedNonFileSchemes,
dartFilters: _server.uriConverter.filters,
pluginTypes: pluginTypes,
);
}
}

View file

@ -0,0 +1,24 @@
// 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.
mixin TestMacros {
/// A macro that can be applied to a class to add a `foo()` method that calls
/// a bar() method.
final withFooMethodMacro = r'''
// There is no public API exposed yet, the in-progress API lives here.
import 'package:_fe_analyzer_shared/src/macros/api.dart';
macro class WithFoo implements ClassDeclarationsMacro {
const WithFoo();
@override
Future<void> buildDeclarationsForClass(
ClassDeclaration clazz,
MemberDeclarationBuilder builder,
) async {
builder.declareInType(DeclarationCode.fromString('void foo() {bar();}'));
}
}
''';
}

View file

@ -2,8 +2,15 @@
// 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/lsp_protocol/protocol.dart';
import 'package:path/path.dart' as path;
/// The file suffix used for virtual macro files in the analyzer.
const macroClientFileSuffix = '.macro.dart';
/// The URI scheme used for virtual macro files on the client.
const macroClientUriScheme = 'dart-macro+file';
/// A class for converting between internal analyzer file paths/references and
/// URIs used by clients.
///
@ -13,19 +20,79 @@ import 'package:path/path.dart' as path;
class ClientUriConverter {
final path.Context _context;
/// The URI schemes that are supported by this converter.
///
/// This always includes 'file' and may optionally include others like
/// 'dart-macro+file'.
final Set<String> supportedSchemes;
/// The URI schemes that are supported by this converter except 'file'.
final Set<String> supportedNonFileSchemes;
/// A set of document filters for Dart files in the supported schemes.
final List<TextDocumentFilterWithScheme> filters;
/// Creates a converter that does nothing besides translation between file
/// paths and `file://` URIs.
ClientUriConverter.noop(this._context);
ClientUriConverter.noop(path.Context context) : this._(context);
/// Creates a converter that translates paths/URIs for virtual files such as
/// those created by macros.
ClientUriConverter.withVirtualFileSupport(path.Context context)
: this._(context, {macroClientUriScheme});
ClientUriConverter._(this._context, [this.supportedNonFileSchemes = const {}])
: supportedSchemes = {'file', ...supportedNonFileSchemes},
filters = [
for (var scheme in {'file', ...supportedNonFileSchemes})
TextDocumentFilterWithScheme(language: 'dart', scheme: scheme)
];
/// Converts a URI provided by the client into a file path/reference that can
/// be used by the analyzer.
String fromClientUri(Uri uri) {
return _context.fromUri(uri);
// 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');
}
if (!supportedSchemes.contains(uri.scheme)) {
var supportedSchemesString =
supportedSchemes.map((scheme) => "'$scheme'").join(', ');
throw ArgumentError.value(
uri,
'uri',
"URI scheme '${uri.scheme}' is not supported. "
'Allowed schemes are $supportedSchemesString.',
);
}
switch (uri.scheme) {
// Map macro scheme back to 'file:///.../x.macro.dart'.
case macroClientUriScheme:
var pathWithoutExtension =
uri.path.substring(0, uri.path.length - '.dart'.length);
var newPath = '$pathWithoutExtension$macroClientFileSuffix';
return _context.fromUri(uri.replace(scheme: 'file', path: newPath));
default:
return _context.fromUri(uri);
}
}
/// Converts a file path/reference from the analyzer into a URI to be sent to
/// the client.
Uri toClientUri(String filePath) {
// Map '/.../x.macro.dart' onto macro scheme.
if (filePath.endsWith(macroClientFileSuffix) &&
supportedSchemes.contains(macroClientUriScheme)) {
var pathWithoutSuffix =
filePath.substring(0, filePath.length - macroClientFileSuffix.length);
var newPath = '$pathWithoutSuffix.dart';
return _context.toUri(newPath).replace(scheme: macroClientUriScheme);
}
return _context.toUri(filePath);
}
}

View file

@ -0,0 +1,173 @@
// 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/src/legacy_analysis_server.dart';
import 'package:analysis_server/src/lsp/test_macros.dart';
import 'package:analysis_server/src/utilities/client_uri_converter.dart';
import 'package:analyzer/src/dart/analysis/experiments.dart';
import 'package:language_server_protocol/protocol_generated.dart';
import 'package:test/expect.dart';
import 'package:test_reflective_loader/test_reflective_loader.dart';
import '../tool/lsp_spec/matchers.dart';
import 'server_abstract.dart';
void main() {
defineReflectiveSuite(() {
defineReflectiveTests(DartTextDocumentContentProviderTest);
});
}
@reflectiveTest
class DartTextDocumentContentProviderTest extends AbstractLspAnalysisServerTest
with TestMacros {
@override
AnalysisServerOptions get serverOptions => AnalysisServerOptions()
..enabledExperiments = [
...super.serverOptions.enabledExperiments,
EnableString.macros,
];
@override
void setUp() {
super.setUp();
setDartTextDocumentContentProviderSupport();
}
Future<void> test_invalid_badScheme() async {
await initialize();
await expectLater(
getDartTextDocumentContent(Uri.parse('abcde:foo/bar.dart')),
throwsA(
isResponseError(
ErrorCodes.InvalidParams,
message: "Fetching content for scheme 'abcde' is not supported. "
"Supported schemes are '$macroClientUriScheme'.",
),
),
);
}
Future<void> test_invalid_fileScheme() async {
await initialize();
await expectLater(
getDartTextDocumentContent(mainFileUri),
throwsA(
isResponseError(
ErrorCodes.InvalidParams,
message: "Fetching content for scheme 'file' is not supported. "
"Supported schemes are '$macroClientUriScheme'.",
),
),
);
}
Future<void> test_support_notSupported() async {
setDartTextDocumentContentProviderSupport(false);
await initialize();
expect(
experimentalServerCapabilities['dartTextDocumentContentProvider'],
isNull,
);
}
Future<void> test_supported_static() async {
await initialize();
expect(
experimentalServerCapabilities['dartTextDocumentContentProvider'],
{
'schemes': [macroClientUriScheme],
},
);
}
Future<void> test_valid_content() async {
writePackageConfig(projectFolderPath, temporaryMacroSupport: true);
newFile(
join(projectFolderPath, 'lib', 'with_foo.dart'), withFooMethodMacro);
var content = '''
import 'with_foo.dart';
f() {
A().foo();
}
@WithFoo()
class A {
void bar() {}
}
''';
await initialize();
await Future.wait([
openFile(mainFileUri, content),
waitForAnalysisComplete(),
]);
var macroGeneratedContent =
await getDartTextDocumentContent(mainFileMacroUri);
// 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<void> test_valid_eventAndModifiedContent() async {
writePackageConfig(projectFolderPath, temporaryMacroSupport: 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() {}
}
''';
await initialize();
await Future.wait([
openFile(mainFileUri, content),
waitForAnalysisComplete(),
]);
// Verify initial contents of the macro.
var macroGeneratedContent =
await getDartTextDocumentContent(mainFileMacroUri);
expect(macroGeneratedContent!.content, contains('void foo() {'));
// Modify the macro and expect a change event.
await Future.wait([
dartTextDocumentContentDidChangeNotifications
.firstWhere((notification) => notification.uri == mainFileMacroUri),
// Replace the macro implementation to produce a `foo2()` method instead
// of `foo()`.
openFile(
toUri(macroImplementationFilePath),
withFooMethodMacro.replaceAll('void foo() {', 'void foo2() {'),
)
]);
// Verify updated contents of the macro.
macroGeneratedContent = await getDartTextDocumentContent(mainFileMacroUri);
expect(macroGeneratedContent!.content, contains('void foo2() {'));
}
}

View file

@ -4,6 +4,9 @@
import 'package:analysis_server/lsp_protocol/protocol.dart' as lsp;
import 'package:analysis_server/src/analysis_server.dart';
import 'package:analysis_server/src/legacy_analysis_server.dart';
import 'package:analysis_server/src/lsp/test_macros.dart';
import 'package:analyzer/src/dart/analysis/experiments.dart';
import 'package:analyzer/src/test_utilities/test_code_format.dart';
import 'package:analyzer_plugin/protocol/protocol_common.dart';
import 'package:analyzer_plugin/protocol/protocol_generated.dart' as plugin;
@ -20,7 +23,14 @@ void main() {
}
@reflectiveTest
class DefinitionTest extends AbstractLspAnalysisServerTest {
class DefinitionTest extends AbstractLspAnalysisServerTest with TestMacros {
@override
AnalysisServerOptions get serverOptions => AnalysisServerOptions()
..enabledExperiments = [
...super.serverOptions.enabledExperiments,
EnableString.macros,
];
Future<void> test_acrossFiles() async {
final mainContents = '''
import 'referenced.dart';
@ -414,6 +424,96 @@ foo(int m) {
);
}
Future<void> test_macro_macroGeneratedFileToUserFile() async {
writePackageConfig(projectFolderPath, temporaryMacroSupport: true);
setLocationLinkSupport(); // To verify the full set of ranges.
setDartTextDocumentContentProviderSupport();
newFile(
join(projectFolderPath, 'lib', 'with_foo.dart'), withFooMethodMacro);
final code = TestCode.parse('''
import 'with_foo.dart';
f() {
A().foo();
}
@WithFoo()
class A {
/*[0*/void /*[1*/bar/*1]*/() {}/*0]*/
}
''');
await initialize();
await Future.wait([
openFile(mainFileUri, code.code),
waitForAnalysisComplete(),
]);
// Find the location of the call to bar() in the macro file so we can
// invoke Definition on it.
var macroResponse = await getDartTextDocumentContent(mainFileMacroUri);
var macroContent = macroResponse!.content!;
var barInvocationRange = rangeOfStringInString(macroContent, 'bar');
// Invoke Definition in the macro file at the location of the call back to
// the main file.
var locations = await getDefinitionAsLocationLinks(
mainFileMacroUri, barInvocationRange.start);
var location = locations.single;
// Check the origin selection range covers the text we'd expected in the
// generated file.
expect(
getTextForRange(macroContent, location.originSelectionRange!), 'bar');
// And the target matches our original file.
expect(location.targetUri, mainFileUri);
expect(location.targetRange, code.ranges[0].range);
expect(location.targetSelectionRange, code.ranges[1].range);
}
Future<void> test_macro_userFileToMacroGeneratedFile() async {
writePackageConfig(projectFolderPath, temporaryMacroSupport: true);
// TODO(dantup): Consider making LocationLink the default for tests (with
// some specific tests for Location) because it's what VS Code uses and
// has more fields to verify.
setLocationLinkSupport(); // To verify the full set of ranges.
setDartTextDocumentContentProviderSupport();
newFile(
join(projectFolderPath, 'lib', 'with_foo.dart'), withFooMethodMacro);
final code = TestCode.parse('''
import 'with_foo.dart';
f() {
A().[!foo^!]();
}
@WithFoo()
class A {}
''');
await initialize();
await openFile(mainFileUri, code.code);
var locations =
await getDefinitionAsLocationLinks(mainFileUri, code.position.position);
var location = locations.single;
expect(location.originSelectionRange, code.range.range);
expect(location.targetUri, mainFileMacroUri);
// To verify the other ranges, fetch the content for the file and check
// those substrings are as expected.
var macroResponse = await getDartTextDocumentContent(location.targetUri);
var macroContent = macroResponse!.content!;
expect(getTextForRange(macroContent, location.targetRange),
'void foo() {bar();}');
expect(getTextForRange(macroContent, location.targetSelectionRange), 'foo');
}
Future<void> test_nonDartFile() async {
newFile(pubspecFilePath, simplePubspecContent);
await initialize();

View file

@ -79,11 +79,11 @@ String b = "Test";
await initialize();
await openFile(mainFileUri, initialContents);
await pumpEventQueue(times: 5000);
expect(diagnostics[mainFilePath], isNull);
expect(diagnostics[mainFileUri], isNull);
await replaceFile(222, mainFileUri, 'String a = 1;');
await pumpEventQueue(times: 5000);
expect(diagnostics[mainFilePath], isNotEmpty);
expect(diagnostics[mainFileUri], isNotEmpty);
}
Future<void> test_analysisOptionsFile() async {
@ -551,7 +551,7 @@ analyzer:
initialize,
{'showTodos': true},
);
expect(diagnostics[mainFilePath], hasLength(2));
expect(diagnostics[mainFileUri], hasLength(2));
}
Future<void> test_todos_disabled() async {
@ -564,7 +564,7 @@ analyzer:
// TODOs are disabled by default so we don't need to send any config.
await initialize();
await pumpEventQueue(times: 5000);
expect(diagnostics[mainFilePath], isNull);
expect(diagnostics[mainFileUri], isNull);
}
Future<void> test_todos_enabledAfterAnalysis() async {
@ -576,11 +576,11 @@ analyzer:
await provideConfig(initialize, {});
await openFile(mainFileUri, contents);
await initialAnalysis;
expect(diagnostics[mainFilePath], isNull);
expect(diagnostics[mainFileUri], isNull);
await updateConfig({'showTodos': true});
await waitForAnalysisComplete();
expect(diagnostics[mainFilePath], hasLength(1));
expect(diagnostics[mainFileUri], hasLength(1));
}
Future<void> test_todos_specific() async {
@ -603,7 +603,7 @@ analyzer:
);
await initialAnalysis;
final initialDiagnostics = diagnostics[mainFilePath]!;
final initialDiagnostics = diagnostics[mainFileUri]!;
expect(initialDiagnostics, hasLength(2));
expect(
initialDiagnostics.map((e) => e.code!),

View file

@ -130,7 +130,7 @@ class Bar {
// Expect diagnostics, because changing the content will have triggered
// analysis.
expect(diagnostics[mainFilePath], isNotEmpty);
expect(diagnostics[mainFileUri], isNotEmpty);
}
Future<void> test_documentOpen_contentUnchanged_noAnalysis() async {
@ -145,7 +145,7 @@ class Bar {
// Expect no diagnostics because the file didn't actually change content
// when the overlay was created, so it should not have triggered analysis.
expect(diagnostics[mainFilePath], isNull);
expect(diagnostics[mainFileUri], isNull);
}
Future<void> test_documentOpen_createsOverlay() async {
@ -209,24 +209,24 @@ class Bar {
// content.
await initialize();
await pumpEventQueue(times: 5000);
expect(diagnostics[mainFilePath], isNotEmpty);
expect(diagnostics[mainFileUri], isNotEmpty);
// Expect diagnostics after opening the file with the same contents.
await openFile(mainFileUri, content);
await pumpEventQueue(times: 5000);
expect(diagnostics[mainFilePath], isNotEmpty);
expect(diagnostics[mainFileUri], isNotEmpty);
// Expect diagnostics after deleting the file because the overlay is still
// active.
deleteFile(mainFilePath);
await pumpEventQueue(times: 5000);
expect(diagnostics[mainFilePath], isNotEmpty);
expect(diagnostics[mainFileUri], isNotEmpty);
// Expect diagnostics to be removed after we close the file (which removes
// the overlay).
await closeFile(mainFileUri);
await pumpEventQueue(times: 5000);
expect(diagnostics[mainFilePath], isEmpty);
expect(diagnostics[mainFileUri], isEmpty);
}
/// Tests that deleting and re-creating a file while an overlay is active
@ -243,35 +243,35 @@ class Bar {
// content.
await initialize();
await pumpEventQueue(times: 5000);
expect(diagnostics[mainFilePath], isNotEmpty);
expect(diagnostics[mainFileUri], isNotEmpty);
// Expect diagnostics after opening the file with the same contents.
await openFile(mainFileUri, content);
await pumpEventQueue(times: 5000);
expect(diagnostics[mainFilePath], isNotEmpty);
expect(diagnostics[mainFileUri], isNotEmpty);
// Expect diagnostics after deleting the file because the overlay is still
// active.
deleteFile(mainFilePath);
await pumpEventQueue(times: 5000);
expect(diagnostics[mainFilePath], isNotEmpty);
expect(diagnostics[mainFileUri], isNotEmpty);
// Expect diagnostics remain after re-creating the file (the overlay is still
// active).
newFile(mainFilePath, content);
await pumpEventQueue(times: 5000);
expect(diagnostics[mainFilePath], isNotEmpty);
expect(diagnostics[mainFileUri], isNotEmpty);
// Expect diagnostics remain after we close the file because the file still
//exists on disk.
await closeFile(mainFileUri);
await pumpEventQueue(times: 5000);
expect(diagnostics[mainFilePath], isNotEmpty);
expect(diagnostics[mainFileUri], isNotEmpty);
// Finally, expect deleting the file clears the diagnostics.
deleteFile(mainFilePath);
await pumpEventQueue(times: 5000);
expect(diagnostics[mainFilePath], isEmpty);
expect(diagnostics[mainFileUri], isEmpty);
}
Future<void> test_documentOpen_notifiesPlugins() async {
@ -290,6 +290,7 @@ class Bar {
Future<void> test_documentOpen_processesOverlay_dartSdk_issue51159() async {
final binFolder = convertPath(join(projectFolderPath, 'bin'));
final binMainFilePath = convertPath(join(binFolder, 'main.dart'));
final binMainFileUri = pathContext.toUri(binMainFilePath);
final fooFilePath = convertPath(join(binFolder, 'foo.dart'));
final fooUri = pathContext.toUri(fooFilePath);
@ -309,7 +310,7 @@ class Foo {}
await initialAnalysis;
// Expect diagnostics because 'foo.dart' doesn't exist.
expect(diagnostics[binMainFilePath], isNotEmpty);
expect(diagnostics[binMainFileUri], isNotEmpty);
// Create the file and _immediately_ open it, so the file exists when the
// overlay is created, even though the watcher event has not been processed.
@ -320,7 +321,7 @@ class Foo {}
]);
// Expect the diagnostics have gone.
expect(diagnostics[binMainFilePath], isEmpty);
expect(diagnostics[binMainFileUri], isEmpty);
}
Future<void> test_documentOpen_setsPriorityFileIfEarly() async {

View file

@ -145,7 +145,8 @@ class FileModificationTest extends AbstractLspAnalysisServerTest {
expect(notificationParams, isNotNull);
expect(
notificationParams.message,
contains('URI was not a valid file:// URI'),
contains(
"URI scheme 'http' is not supported. Allowed schemes are 'file'."),
);
}

View file

@ -672,7 +672,7 @@ void f() {
Uri.parse(mainFileUri.toString() + r'###***\\\///:::.dart'),
),
throwsA(isResponseError(ServerErrorCodes.InvalidFilePath,
message: 'File URI did not contain a valid file path')),
message: 'URI does not contain a valid file path')),
);
}
@ -682,7 +682,8 @@ void f() {
await expectLater(
formatDocument(Uri.parse('a:/a.dart')),
throwsA(isResponseError(ServerErrorCodes.InvalidFilePath,
message: 'URI was not a valid file:// URI')),
message:
"URI scheme 'a' is not supported. Allowed schemes are 'file'.")),
);
}

View file

@ -78,6 +78,13 @@ mixin LspEditHelpersMixin {
indexedEdits.sort(TextEditWithIndex.compare);
return indexedEdits.map((e) => e.edit).fold(content, applyTextEdit);
}
/// Returns the text for [range] in [content].
String getTextForRange(String content, Range range) {
var lineInfo = LineInfo.fromContent(content);
var sourceRange = toSourceRange(lineInfo, range).result;
return content.substring(sourceRange.offset, sourceRange.end);
}
}
/// Helpers to simplify building LSP requests for use in tests.
@ -250,6 +257,15 @@ mixin LspRequestHelpersMixin {
return completions;
}
Future<DartTextDocumentContent?> getDartTextDocumentContent(Uri uri) {
final request = makeRequest(
CustomMethods.dartTextDocumentContent,
DartTextDocumentContentParams(uri: uri),
);
return expectSuccessfulResponseTo(
request, DartTextDocumentContent.fromJson);
}
Future<Either2<List<Location>, List<LocationLink>>> getDefinition(
Uri uri, Position pos) {
final request = makeRequest(

View file

@ -7,6 +7,7 @@ import 'dart:async';
import 'package:analysis_server/lsp_protocol/protocol.dart';
import 'package:analysis_server/src/analytics/analytics_manager.dart';
import 'package:analysis_server/src/legacy_analysis_server.dart';
import 'package:analysis_server/src/lsp/client_capabilities.dart';
import 'package:analysis_server/src/lsp/constants.dart';
import 'package:analysis_server/src/lsp/lsp_analysis_server.dart';
import 'package:analysis_server/src/plugin/plugin_manager.dart';
@ -14,6 +15,7 @@ import 'package:analysis_server/src/server/crash_reporting_attachments.dart';
import 'package:analysis_server/src/services/user_prompts/dart_fix_prompt_manager.dart';
import 'package:analysis_server/src/utilities/client_uri_converter.dart';
import 'package:analysis_server/src/utilities/mocks.dart';
import 'package:analyzer/file_system/physical_file_system.dart';
import 'package:analyzer/instrumentation/instrumentation.dart';
import 'package:analyzer/src/dart/analysis/experiments.dart';
import 'package:analyzer/src/generated/sdk.dart';
@ -24,6 +26,7 @@ import 'package:analyzer/src/test_utilities/test_code_format.dart';
import 'package:analyzer/src/util/file_paths.dart' as file_paths;
import 'package:analyzer_plugin/protocol/protocol.dart' as plugin;
import 'package:analyzer_plugin/src/protocol/protocol_internal.dart' as plugin;
import 'package:analyzer_utilities/package_root.dart' as package_root;
import 'package:collection/collection.dart';
import 'package:language_server_protocol/json_parsing.dart';
import 'package:path/path.dart' as path;
@ -559,6 +562,18 @@ mixin ClientCapabilitiesHelperMixin {
workspaceCapabilities, {'configuration': true});
}
void setDartTextDocumentContentProviderSupport([bool supported = true]) {
// These are temporarily versioned with a suffix during dev so if we ship
// as an experiment (not LSP standard) without the suffix it will only be
// active for matching server/clients.
const key = dartExperimentalTextDocumentContentProviderKey;
if (supported) {
experimentalCapabilities[key] = true;
} else {
experimentalCapabilities.remove(key);
}
}
void setDiagnosticCodeDescriptionSupport() {
textDocumentCapabilities =
extendTextDocumentCapabilities(textDocumentCapabilities, {
@ -766,6 +781,9 @@ mixin ConfigurationFilesMixin on ResourceProviderMixin {
bool meta = false,
bool pedantic = false,
bool vector_math = false,
// TODO(dantup): Remove this flag when we no longer need to copy packages
// for macro support.
bool temporaryMacroSupport = false,
}) {
if (config == null) {
config = PackageConfigFileBuilder();
@ -805,6 +823,32 @@ mixin ConfigurationFilesMixin on ResourceProviderMixin {
config.add(name: 'vector_math', rootPath: libFolder.parent.path);
}
if (temporaryMacroSupport) {
final testPackagesRootPath = resourceProvider.convertPath('/packages');
final physical = PhysicalResourceProvider.INSTANCE;
final packageRoot =
physical.pathContext.normalize(package_root.packageRoot);
// Copy _fe_analyzer_shared from local SDK into the memory FS.
final testSharedFolder =
getFolder('$testPackagesRootPath/_fe_analyzer_shared');
physical
.getFolder(packageRoot)
.getChildAssumingFolder('_fe_analyzer_shared/lib/src/macros')
.copyTo(testSharedFolder.getChildAssumingFolder('lib/src'));
config.add(name: '_fe_analyzer_shared', rootPath: testSharedFolder.path);
// Copy dart_internal from local SDK into the memory FS.
final testInternalFolder =
getFolder('$testPackagesRootPath/dart_internal');
physical
.getFolder(packageRoot)
.getChildAssumingFolder('dart_internal')
.copyTo(testInternalFolder);
config.add(name: 'dart_internal', rootPath: testInternalFolder.path);
}
var path = '$projectFolderPath/.dart_tool/package_config.json';
var content = config.toContent(toUriStr: toUriStr);
newFile(path, content);
@ -847,19 +891,36 @@ mixin LspAnalysisServerTestMixin
/// A file that has never had diagnostics will not be in the map. A file that
/// has ever had diagnostics will be in the map, even if the entry is an empty
/// list.
final diagnostics = <String, List<Diagnostic>>{};
final diagnostics = <Uri, List<Diagnostic>>{};
/// A stream of [OpenUriParams] for any `dart/openUri` notifications.
Stream<DartTextDocumentContentDidChangeParams>
get dartTextDocumentContentDidChangeNotifications =>
notificationsFromServer
.where((notification) =>
notification.method ==
CustomMethods.dartTextDocumentContentDidChange)
.map((message) => DartTextDocumentContentDidChangeParams.fromJson(
message.params as Map<String, Object?>));
/// A stream of [NotificationMessage]s from the server that may be errors.
Stream<NotificationMessage> get errorNotificationsFromServer {
return notificationsFromServer.where(_isErrorNotification);
}
/// The experimental capabilities returned from the server during initialization.
Map<String, Object?> get experimentalServerCapabilities =>
serverCapabilities.experimental as Map<String, Object?>? ?? {};
/// A [Future] that completes with the first analysis after initialization.
Future<void> get initialAnalysis =>
initialized ? Future.value() : waitForAnalysisComplete();
bool get initialized => _clientCapabilities != null;
/// The URI for the macro-generated contents for [mainFileUri].
Uri get mainFileMacroUri => mainFileUri.replace(scheme: macroClientUriScheme);
/// A stream of [NotificationMessage]s from the server.
Stream<NotificationMessage> get notificationsFromServer {
return serverToClient
@ -891,6 +952,9 @@ mixin LspAnalysisServerTestMixin
.cast<RequestMessage>();
}
/// The capabilities returned from the server during initialization.
ServerCapabilities get serverCapabilities => _serverCapabilities!;
Stream<Message> get serverToClient;
Future<void> changeFile(
@ -1304,6 +1368,15 @@ mixin LspAnalysisServerTestMixin
Range rangeOfString(TestCode code, String searchText) =>
rangeOfPattern(code, searchText);
/// Returns the range of [searchText] in [content].
Range rangeOfStringInString(String content, String searchText) {
final match = searchText.allMatches(content).first;
return Range(
start: positionFromOffset(match.start, content),
end: positionFromOffset(match.end, content),
);
}
/// Returns a [Range] that covers the entire of [content].
Range rangeOfWholeContent(String content) {
return Range(
@ -1416,13 +1489,11 @@ mixin LspAnalysisServerTestMixin
/// Records the latest diagnostics for each file in [latestDiagnostics].
///
/// [latestDiagnostics] maps from a file path to the set of current
/// diagnostics.
/// [latestDiagnostics] maps from a URI to the set of current diagnostics.
StreamSubscription<PublishDiagnosticsParams> trackDiagnostics(
Map<String, List<Diagnostic>> latestDiagnostics) {
Map<Uri, List<Diagnostic>> latestDiagnostics) {
return publishedDiagnostics.listen((diagnostics) {
latestDiagnostics[pathContext.fromUri(diagnostics.uri)] =
diagnostics.diagnostics;
latestDiagnostics[diagnostics.uri] = diagnostics.diagnostics;
});
}

View file

@ -107,7 +107,7 @@ class ServerTest extends AbstractLspAnalysisServerTest {
]),
);
expect(diagnostics[mainFilePath]!.single.code, 'undefined_class');
expect(diagnostics[mainFileUri]!.single.code, 'undefined_class');
}
Future<void> test_capturesLatency_afterStartup() async {
@ -181,7 +181,7 @@ class ServerTest extends AbstractLspAnalysisServerTest {
Uri.parse(mainFileUri.toString() + r'###***\\\///:::.dart'),
),
throwsA(isResponseError(ServerErrorCodes.InvalidFilePath,
message: 'File URI did not contain a valid file path')),
message: 'URI does not contain a valid file path')),
);
}
@ -200,7 +200,8 @@ class ServerTest extends AbstractLspAnalysisServerTest {
await expectLater(
getHover(missingDriveLetterFileUri, startOfDocPos),
throwsA(isResponseError(ServerErrorCodes.InvalidFilePath,
message: 'URI was not an absolute file path (missing drive letter)')),
message:
'URI does not contain an absolute file path (missing drive letter)')),
);
}
@ -210,7 +211,8 @@ class ServerTest extends AbstractLspAnalysisServerTest {
await expectLater(
getHover(relativeFileUri, startOfDocPos),
throwsA(isResponseError(ServerErrorCodes.InvalidFilePath,
message: 'URI was not a valid file:// URI')),
message:
"URI scheme 'foo' is not supported. Allowed schemes are 'file'.")),
);
}
@ -222,7 +224,7 @@ class ServerTest extends AbstractLspAnalysisServerTest {
// The pathContext.toUri() above translates to a non-file:// URI of just
// 'a/b.dart' so will get the not-file-scheme error message.
throwsA(isResponseError(ServerErrorCodes.InvalidFilePath,
message: 'URI was not a valid file:// URI')),
message: 'URI is not a valid file:// URI')),
);
}

View file

@ -43,7 +43,7 @@ class TemporaryOverlayOperationTest extends AbstractLspAnalysisServerTest {
}).doWork();
await pumpEventQueue(times: 5000);
expect(diagnostics[mainFilePath], isNull);
expect(diagnostics[mainFileUri], isNull);
}
Future<void> test_pausesRequestQueue() async {

View file

@ -18,6 +18,8 @@ import 'code_actions_source_test.dart' as code_actions_source;
import 'completion_dart_test.dart' as completion_dart;
import 'completion_yaml_test.dart' as completion_yaml;
import 'configuration_test.dart' as configuration;
import 'dart_text_document_content_provider_test.dart'
as dart_text_document_content_provider;
import 'definition_test.dart' as definition;
import 'diagnostic_test.dart' as diagnostic;
import 'document_changes_test.dart' as document_changes;
@ -69,6 +71,7 @@ void main() {
completion_dart.main();
completion_yaml.main();
configuration.main();
dart_text_document_content_provider.main();
definition.main();
diagnostic.main();
document_changes.main();

View file

@ -237,3 +237,18 @@ Params: `{ uri: Uri }`
Notifies the client that the server would like to open a given URI. This event is only sent in response to direct user actions (such as if the user clicks a "Learn More" button in a `window/showMessageRequest`). URIs could be either external web pages (http/https) to be opened in the browser or documents (file:///) to be opened in the editor.
This notification (and functionality that relies on it) will only be sent if the client passes `allowOpenUri: true` in `initializationOptions`.
### dart/textDocumentContent Method (Experimental)
Direction: Client -> Server
Params: `{ uri: Uri }`
Returns: `{ content: string | undefined }`
Returns the content of the the virtual document with `uri`. This is intended for generated files (such as those generated by macros) and is not supported for 'file' URIs.
### dart/textDocumentContentDidChange Notification
Direction: Server -> Client
Params: `{ uri: Uri }`
Notifies the client that the content in the virtual file with `uri` may have changed (for example because a macro executed and regenerated its content).

View file

@ -498,6 +498,47 @@ List<LspEntity> getCustomClasses() {
baseType: 'CommandParameter',
comment: 'Information about a Save URI argument needed by the command.',
),
interface(
'DartTextDocumentContentProviderRegistrationOptions',
[
field(
'schemes',
type: 'string',
array: true,
comment: 'A set of URI schemes the server can provide content for. '
'The server may also return URIs with these schemes in responses '
'to other requests.',
),
],
),
interface(
'DartTextDocumentContentParams',
[
field(
'uri',
type: 'DocumentUri',
),
],
),
interface(
'DartTextDocumentContent',
[
field(
'content',
type: 'String',
canBeNull: true,
),
],
),
interface(
'DartTextDocumentContentDidChangeParams',
[
field(
'uri',
type: 'DocumentUri',
),
],
),
];
return customTypes;
}

View file

@ -992,6 +992,218 @@ class DartDiagnosticServer implements ToJsonable {
}
}
class DartTextDocumentContent implements ToJsonable {
static const jsonHandler = LspJsonHandler(
DartTextDocumentContent.canParse,
DartTextDocumentContent.fromJson,
);
final String? content;
DartTextDocumentContent({
this.content,
});
@override
int get hashCode => content.hashCode;
@override
bool operator ==(Object other) {
return other is DartTextDocumentContent &&
other.runtimeType == DartTextDocumentContent &&
content == other.content;
}
@override
Map<String, Object?> toJson() {
var result = <String, Object?>{};
result['content'] = content;
return result;
}
@override
String toString() => jsonEncoder.convert(toJson());
static bool canParse(Object? obj, LspJsonReporter reporter) {
if (obj is Map<String, Object?>) {
return _canParseString(obj, reporter, 'content',
allowsUndefined: false, allowsNull: true);
} else {
reporter.reportError('must be of type DartTextDocumentContent');
return false;
}
}
static DartTextDocumentContent fromJson(Map<String, Object?> json) {
final contentJson = json['content'];
final content = contentJson as String?;
return DartTextDocumentContent(
content: content,
);
}
}
class DartTextDocumentContentDidChangeParams implements ToJsonable {
static const jsonHandler = LspJsonHandler(
DartTextDocumentContentDidChangeParams.canParse,
DartTextDocumentContentDidChangeParams.fromJson,
);
final DocumentUri uri;
DartTextDocumentContentDidChangeParams({
required this.uri,
});
@override
int get hashCode => uri.hashCode;
@override
bool operator ==(Object other) {
return other is DartTextDocumentContentDidChangeParams &&
other.runtimeType == DartTextDocumentContentDidChangeParams &&
uri == other.uri;
}
@override
Map<String, Object?> toJson() {
var result = <String, Object?>{};
result['uri'] = uri.toString();
return result;
}
@override
String toString() => jsonEncoder.convert(toJson());
static bool canParse(Object? obj, LspJsonReporter reporter) {
if (obj is Map<String, Object?>) {
return _canParseUri(obj, reporter, 'uri',
allowsUndefined: false, allowsNull: false);
} else {
reporter.reportError(
'must be of type DartTextDocumentContentDidChangeParams');
return false;
}
}
static DartTextDocumentContentDidChangeParams fromJson(
Map<String, Object?> json) {
final uriJson = json['uri'];
final uri = Uri.parse(uriJson as String);
return DartTextDocumentContentDidChangeParams(
uri: uri,
);
}
}
class DartTextDocumentContentParams implements ToJsonable {
static const jsonHandler = LspJsonHandler(
DartTextDocumentContentParams.canParse,
DartTextDocumentContentParams.fromJson,
);
final DocumentUri uri;
DartTextDocumentContentParams({
required this.uri,
});
@override
int get hashCode => uri.hashCode;
@override
bool operator ==(Object other) {
return other is DartTextDocumentContentParams &&
other.runtimeType == DartTextDocumentContentParams &&
uri == other.uri;
}
@override
Map<String, Object?> toJson() {
var result = <String, Object?>{};
result['uri'] = uri.toString();
return result;
}
@override
String toString() => jsonEncoder.convert(toJson());
static bool canParse(Object? obj, LspJsonReporter reporter) {
if (obj is Map<String, Object?>) {
return _canParseUri(obj, reporter, 'uri',
allowsUndefined: false, allowsNull: false);
} else {
reporter.reportError('must be of type DartTextDocumentContentParams');
return false;
}
}
static DartTextDocumentContentParams fromJson(Map<String, Object?> json) {
final uriJson = json['uri'];
final uri = Uri.parse(uriJson as String);
return DartTextDocumentContentParams(
uri: uri,
);
}
}
class DartTextDocumentContentProviderRegistrationOptions implements ToJsonable {
static const jsonHandler = LspJsonHandler(
DartTextDocumentContentProviderRegistrationOptions.canParse,
DartTextDocumentContentProviderRegistrationOptions.fromJson,
);
/// A set of URI schemes the server can provide content for. The server may
/// also return URIs with these schemes in responses to other requests.
final List<String> schemes;
DartTextDocumentContentProviderRegistrationOptions({
required this.schemes,
});
@override
int get hashCode => lspHashCode(schemes);
@override
bool operator ==(Object other) {
return other is DartTextDocumentContentProviderRegistrationOptions &&
other.runtimeType ==
DartTextDocumentContentProviderRegistrationOptions &&
const DeepCollectionEquality().equals(schemes, other.schemes);
}
@override
Map<String, Object?> toJson() {
var result = <String, Object?>{};
result['schemes'] = schemes;
return result;
}
@override
String toString() => jsonEncoder.convert(toJson());
static bool canParse(Object? obj, LspJsonReporter reporter) {
if (obj is Map<String, Object?>) {
return _canParseListString(obj, reporter, 'schemes',
allowsUndefined: false, allowsNull: false);
} else {
reporter.reportError(
'must be of type DartTextDocumentContentProviderRegistrationOptions');
return false;
}
}
static DartTextDocumentContentProviderRegistrationOptions fromJson(
Map<String, Object?> json) {
final schemesJson = json['schemes'];
final schemes =
(schemesJson as List<Object?>).map((item) => item as String).toList();
return DartTextDocumentContentProviderRegistrationOptions(
schemes: schemes,
);
}
}
class Element implements ToJsonable {
static const jsonHandler = LspJsonHandler(
Element.canParse,