9831fc4e98
The term "CodeAction" is a bit overloaded. It could mean both an individual result from the `textDocument/codeAction` request (which is a `Command` or a `CodeAction`), or the `CodeAction` type defined in the spec (which the spec refers to as a "Code Action literal"). To reduce confusion where we have similar APIs that operate on "Code Actions" (CodeAction|Command), this renames the `CodeAction` class to `CodeActionLiteral` and we will use the term `CodeAction` to mean either of those types. To make things simpler to review, this change _only_ renames the class, and also swaps the order of the types in some places that used `Either2<Command, CodeAction>` (which is opposite to the spec and some other code). Some further clean up will be done in a separate change. Change-Id: Idcd8265f9229c3450004e68334e98a7b530330a4 Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/425300 Reviewed-by: Samuel Rawlins <srawlins@google.com> Commit-Queue: Brian Wilkerson <brianwilkerson@google.com> Reviewed-by: Brian Wilkerson <brianwilkerson@google.com>
1716 lines
55 KiB
Dart
1716 lines
55 KiB
Dart
// Copyright (c) 2018, 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: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';
|
|
import 'package:analysis_server/src/server/crash_reporting_attachments.dart';
|
|
import 'package:analysis_server/src/server/error_notifier.dart';
|
|
import 'package:analysis_server/src/services/user_prompts/dart_fix_prompt_manager.dart';
|
|
import 'package:analysis_server/src/utilities/mocks.dart';
|
|
import 'package:analyzer/src/generated/sdk.dart';
|
|
import 'package:analyzer/src/test_utilities/mock_sdk.dart';
|
|
import 'package:analyzer/src/test_utilities/resource_provider_mixin.dart';
|
|
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_plugin/src/utilities/client_uri_converter.dart';
|
|
import 'package:analyzer_utilities/test/experiments/experiments.dart';
|
|
import 'package:analyzer_utilities/test/mock_packages/mock_packages.dart';
|
|
import 'package:collection/collection.dart';
|
|
import 'package:language_server_protocol/json_parsing.dart';
|
|
import 'package:meta/meta.dart';
|
|
import 'package:path/path.dart' as path;
|
|
import 'package:test/test.dart' hide expect;
|
|
import 'package:unified_analytics/unified_analytics.dart';
|
|
|
|
import '../constants.dart';
|
|
import '../mocks.dart';
|
|
import '../mocks_lsp.dart';
|
|
import '../shared/shared_test_interface.dart';
|
|
import '../support/configuration_files.dart';
|
|
import '../utils/message_scheduler_test_view.dart';
|
|
import 'change_verifier.dart';
|
|
import 'request_helpers_mixin.dart';
|
|
|
|
const dartLanguageId = 'dart';
|
|
|
|
abstract class AbstractLspAnalysisServerTest
|
|
with
|
|
ResourceProviderMixin,
|
|
ClientCapabilitiesHelperMixin,
|
|
LspRequestHelpersMixin,
|
|
LspEditHelpersMixin,
|
|
LspVerifyEditHelpersMixin,
|
|
LspAnalysisServerTestMixin,
|
|
MockPackagesMixin,
|
|
ConfigurationFilesMixin {
|
|
late MockLspServerChannel channel;
|
|
late ErrorNotifier errorNotifier;
|
|
late TestPluginManager pluginManager;
|
|
MessageSchedulerTestView? testView;
|
|
late LspAnalysisServer server;
|
|
late MockProcessRunner processRunner;
|
|
late MockHttpClient httpClient;
|
|
|
|
/// The number of context builds that had already occurred the last time
|
|
/// resetContextBuildCounter() was called.
|
|
int _previousContextBuilds = 0;
|
|
|
|
DartFixPromptManager? get dartFixPromptManager => null;
|
|
|
|
@override
|
|
LspClientCapabilities get editorClientCapabilities =>
|
|
server.editorClientCapabilities!;
|
|
|
|
String get mainFileAugmentationPath => fromUri(mainFileAugmentationUri);
|
|
|
|
/// The path that is not in [projectFolderPath], contains external packages.
|
|
@override
|
|
String get packagesRootPath => resourceProvider.convertPath('/packages');
|
|
|
|
bool get retainDataForTesting => false;
|
|
|
|
AnalysisServerOptions get serverOptions => AnalysisServerOptions();
|
|
|
|
@override
|
|
Stream<Message> get serverToClient => channel.serverToClient;
|
|
|
|
@override
|
|
ClientUriConverter get uriConverter => server.uriConverter;
|
|
|
|
DiscoveredPluginInfo configureTestPlugin({
|
|
plugin.ResponseResult? respondWith,
|
|
plugin.Notification? notification,
|
|
plugin.ResponseResult? Function(plugin.RequestParams)? handler,
|
|
Duration respondAfter = Duration.zero,
|
|
}) {
|
|
var info = DiscoveredPluginInfo(
|
|
'a',
|
|
'b',
|
|
'c',
|
|
server.notificationManager,
|
|
server.instrumentationService,
|
|
);
|
|
pluginManager.plugins.add(info);
|
|
|
|
if (handler != null) {
|
|
pluginManager.handleRequest = (request) {
|
|
var response = handler(request);
|
|
return response == null
|
|
? null
|
|
: <PluginInfo, Future<plugin.Response>>{
|
|
info: Future.delayed(
|
|
respondAfter,
|
|
).then((_) => response.toResponse('-', 1)),
|
|
};
|
|
};
|
|
}
|
|
|
|
if (respondWith != null) {
|
|
pluginManager.broadcastResults = <PluginInfo, Future<plugin.Response>>{
|
|
info: Future.delayed(
|
|
respondAfter,
|
|
).then((_) => respondWith.toResponse('-', 1)),
|
|
};
|
|
}
|
|
|
|
if (notification != null) {
|
|
server.notificationManager.handlePluginNotification(
|
|
info.pluginId,
|
|
notification,
|
|
);
|
|
}
|
|
|
|
return info;
|
|
}
|
|
|
|
/// Executes [command] which is expected to call back to the client to apply
|
|
/// a [WorkspaceEdit].
|
|
///
|
|
/// Returns a [LspChangeVerifier] that can be used to verify changes.
|
|
Future<LspChangeVerifier> executeCommandForEdits(
|
|
Command command, {
|
|
ProgressToken? workDoneToken,
|
|
}) {
|
|
return executeForEdits(
|
|
() => executeCommand(command, workDoneToken: workDoneToken),
|
|
);
|
|
}
|
|
|
|
void expectContextBuilds() => expect(
|
|
server.contextBuilds - _previousContextBuilds,
|
|
greaterThan(0),
|
|
reason: 'Contexts should have been rebuilt',
|
|
);
|
|
|
|
void expectNoContextBuilds() => expect(
|
|
server.contextBuilds - _previousContextBuilds,
|
|
equals(0),
|
|
reason: 'Contexts should not have been rebuilt',
|
|
);
|
|
|
|
/// Sends a request to the server and unwraps the result. Throws if the
|
|
/// response was not successful or returned an error.
|
|
@override
|
|
Future<T> expectSuccessfulResponseTo<T, R>(
|
|
RequestMessage request,
|
|
T Function(R) fromJson,
|
|
) async {
|
|
var resp = await sendRequestToServer(request);
|
|
var error = resp.error;
|
|
if (error != null) {
|
|
throw error;
|
|
} else {
|
|
// resp.result should only be null when error != null if T allows null.
|
|
return resp.result == null ? null as T : fromJson(resp.result as R);
|
|
}
|
|
}
|
|
|
|
List<TextDocumentEdit> extractTextDocumentEdits(
|
|
DocumentChanges documentChanges,
|
|
) =>
|
|
// Extract TextDocumentEdits from union of resource changes
|
|
documentChanges
|
|
.map(
|
|
(change) => change.map(
|
|
(create) => null,
|
|
(delete) => null,
|
|
(rename) => null,
|
|
(textDocEdit) => textDocEdit,
|
|
),
|
|
)
|
|
.nonNulls
|
|
.toList();
|
|
|
|
@override
|
|
String? getCurrentFileContent(Uri uri) {
|
|
try {
|
|
return server.resourceProvider
|
|
.getFile(pathContext.fromUri(uri))
|
|
.readAsStringSync();
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Finds the registration for a given LSP method.
|
|
Registration? registrationFor(
|
|
List<Registration> registrations,
|
|
Method method,
|
|
) {
|
|
return registrations.singleWhereOrNull((r) => r.method == method.toJson());
|
|
}
|
|
|
|
/// Finds a single registration for a given LSP method with Dart in its
|
|
/// documentSelector.
|
|
///
|
|
/// Throws if there is not exactly one match.
|
|
Registration registrationForDart(
|
|
List<Registration> registrations,
|
|
Method method,
|
|
) => registrationsForDart(registrations, method).single;
|
|
|
|
/// Finds the registrations for a given LSP method with Dart in their
|
|
/// documentSelector.
|
|
List<Registration> registrationsForDart(
|
|
List<Registration> registrations,
|
|
Method method,
|
|
) {
|
|
bool includesDart(Registration r) {
|
|
var options = TextDocumentRegistrationOptions.fromJson(
|
|
r.registerOptions as Map<String, Object?>,
|
|
);
|
|
|
|
return options.documentSelector?.any(
|
|
(selector) =>
|
|
selector.language == dartLanguageId ||
|
|
(selector.pattern?.contains('.dart') ?? false),
|
|
) ??
|
|
false;
|
|
}
|
|
|
|
return registrations
|
|
.where((r) => r.method == method.toJson() && includesDart(r))
|
|
.toList();
|
|
}
|
|
|
|
void resetContextBuildCounter() {
|
|
_previousContextBuilds = server.contextBuilds;
|
|
}
|
|
|
|
Future<ResponseMessage> sendLspRequest(Method method, Object params) {
|
|
return server.sendLspRequest(method, params);
|
|
}
|
|
|
|
@override
|
|
Future<void> sendNotificationToServer(
|
|
NotificationMessage notification,
|
|
) async {
|
|
channel.sendNotificationToServer(notification);
|
|
await pumpEventQueue(times: 5000);
|
|
}
|
|
|
|
@override
|
|
Future<ResponseMessage> sendRequestToServer(RequestMessage request) {
|
|
return channel.sendRequestToServer(request);
|
|
}
|
|
|
|
@override
|
|
void sendResponseToServer(ResponseMessage response) {
|
|
channel.sendResponseToServer(response);
|
|
}
|
|
|
|
@mustCallSuper
|
|
void setUp() {
|
|
httpClient = MockHttpClient();
|
|
processRunner = MockProcessRunner();
|
|
channel = MockLspServerChannel(debugPrintCommunication);
|
|
|
|
// Create an SDK in the mock file system.
|
|
var sdkRoot = newFolder('/sdk');
|
|
createMockSdk(resourceProvider: resourceProvider, root: sdkRoot);
|
|
|
|
errorNotifier = ErrorNotifier();
|
|
pluginManager = TestPluginManager();
|
|
testView = retainDataForTesting ? MessageSchedulerTestView() : null;
|
|
server = LspAnalysisServer(
|
|
channel,
|
|
resourceProvider,
|
|
serverOptions,
|
|
DartSdkManager(sdkRoot.path),
|
|
AnalyticsManager(NoOpAnalytics()),
|
|
CrashReportingAttachmentsBuilder.empty,
|
|
errorNotifier,
|
|
httpClient: httpClient,
|
|
processRunner: processRunner,
|
|
dartFixPromptManager: dartFixPromptManager,
|
|
messageSchedulerListener: testView,
|
|
);
|
|
errorNotifier.server = server;
|
|
server.pluginManager = pluginManager;
|
|
|
|
projectFolderPath = convertPath('/home/my_project');
|
|
newFolder(projectFolderPath);
|
|
newFolder(join(projectFolderPath, 'lib'));
|
|
// Create a folder and file to aid testing that includes imports/completion.
|
|
newFolder(join(projectFolderPath, 'lib', 'folder'));
|
|
newFile(join(projectFolderPath, 'lib', 'file.dart'), '');
|
|
mainFilePath = join(projectFolderPath, 'lib', 'main.dart');
|
|
nonExistentFilePath = join(projectFolderPath, 'lib', 'not_existing.dart');
|
|
pubspecFilePath = join(projectFolderPath, file_paths.pubspecYaml);
|
|
analysisOptionsPath = join(projectFolderPath, 'analysis_options.yaml');
|
|
|
|
var experiments = StringBuffer();
|
|
for (var experiment in experimentsForTests) {
|
|
experiments.writeln(' - $experiment');
|
|
}
|
|
|
|
newFile(analysisOptionsPath, '''
|
|
analyzer:
|
|
enable-experiment:
|
|
$experiments
|
|
''');
|
|
|
|
writeTestPackageConfig();
|
|
}
|
|
|
|
Future<void> tearDown() async {
|
|
channel.close();
|
|
await server.shutdown();
|
|
}
|
|
|
|
/// Verifies that executing the given command on the server results in an edit
|
|
/// being sent in the client that updates the files to match the expected
|
|
/// content.
|
|
Future<LspChangeVerifier> verifyCommandEdits(
|
|
Command command,
|
|
String expectedContent, {
|
|
ProgressToken? workDoneToken,
|
|
}) async {
|
|
var verifier = await executeCommandForEdits(
|
|
command,
|
|
workDoneToken: workDoneToken,
|
|
);
|
|
|
|
verifier.verifyFiles(expectedContent);
|
|
return verifier;
|
|
}
|
|
|
|
LspChangeVerifier verifyEdit(
|
|
WorkspaceEdit edit,
|
|
String expected, {
|
|
Map<Uri, int>? expectedVersions,
|
|
}) {
|
|
var expectDocumentChanges =
|
|
workspaceCapabilities.workspaceEdit?.documentChanges ?? false;
|
|
expect(edit.documentChanges, expectDocumentChanges ? isNotNull : isNull);
|
|
expect(edit.changes, expectDocumentChanges ? isNull : isNotNull);
|
|
|
|
var verifier = LspChangeVerifier(this, edit);
|
|
verifier.verifyFiles(expected, expectedVersions: expectedVersions);
|
|
return verifier;
|
|
}
|
|
|
|
/// Encodes any drive letter colon in the URI.
|
|
///
|
|
/// file:///C:/foo -> file:///C%3A/foo
|
|
Uri withEncodedDriveLetterColon(Uri uri) {
|
|
return uri.replace(path: uri.path.replaceAll(':', '%3A'));
|
|
}
|
|
|
|
/// Adds a trailing slash (direction based on path context) to [path].
|
|
///
|
|
/// Throws if the path already has a trailing slash.
|
|
String withTrailingSlash(String path) {
|
|
var pathSeparator = server.resourceProvider.pathContext.separator;
|
|
expect(path, isNot(endsWith(pathSeparator)));
|
|
return '$path$pathSeparator';
|
|
}
|
|
|
|
/// Adds a trailing slash to [uri].
|
|
///
|
|
/// Throws if the URI already has a trailing slash.
|
|
Uri withTrailingSlashUri(Uri uri) {
|
|
expect(uri.path, isNot(endsWith('/')));
|
|
return uri.replace(path: '${uri.path}/');
|
|
}
|
|
}
|
|
|
|
mixin ClientCapabilitiesHelperMixin {
|
|
final emptyTextDocumentClientCapabilities = TextDocumentClientCapabilities();
|
|
|
|
final emptyWorkspaceClientCapabilities = WorkspaceClientCapabilities();
|
|
|
|
final emptyWindowClientCapabilities = WindowClientCapabilities();
|
|
|
|
/// The set of TextDocument capabilities used if no explicit instance is
|
|
/// passed to [initialize].
|
|
var textDocumentCapabilities = TextDocumentClientCapabilities();
|
|
|
|
/// The set of Workspace capabilities used if no explicit instance is
|
|
/// passed to [initialize].
|
|
var workspaceCapabilities = WorkspaceClientCapabilities();
|
|
|
|
/// The set of Window capabilities used if no explicit instance is
|
|
/// passed to [initialize].
|
|
var windowCapabilities = WindowClientCapabilities();
|
|
|
|
/// The set of experimental capabilities used if no explicit instance is
|
|
/// passed to [initialize].
|
|
var experimentalCapabilities = <String, Object?>{};
|
|
|
|
TextDocumentClientCapabilities extendTextDocumentCapabilities(
|
|
TextDocumentClientCapabilities source,
|
|
Map<String, dynamic> textDocumentCapabilities,
|
|
) {
|
|
var json = source.toJson();
|
|
mergeJson(textDocumentCapabilities, json);
|
|
return TextDocumentClientCapabilities.fromJson(json);
|
|
}
|
|
|
|
WindowClientCapabilities extendWindowCapabilities(
|
|
WindowClientCapabilities source,
|
|
Map<String, dynamic> windowCapabilities,
|
|
) {
|
|
var json = source.toJson();
|
|
mergeJson(windowCapabilities, json);
|
|
return WindowClientCapabilities.fromJson(json);
|
|
}
|
|
|
|
WorkspaceClientCapabilities extendWorkspaceCapabilities(
|
|
WorkspaceClientCapabilities source,
|
|
Map<String, dynamic> workspaceCapabilities,
|
|
) {
|
|
var json = source.toJson();
|
|
mergeJson(workspaceCapabilities, json);
|
|
return WorkspaceClientCapabilities.fromJson(json);
|
|
}
|
|
|
|
void mergeJson(Map<String, dynamic> source, Map<String, dynamic> dest) {
|
|
for (var key in source.keys) {
|
|
var sourceValue = source[key];
|
|
var destValue = dest[key];
|
|
if (sourceValue is Map<String, dynamic> &&
|
|
destValue is Map<String, dynamic>) {
|
|
mergeJson(sourceValue, destValue);
|
|
} else {
|
|
dest[key] = source[key];
|
|
}
|
|
}
|
|
}
|
|
|
|
void setAllSupportedTextDocumentDynamicRegistrations() {
|
|
// This list (when combined with the workspace list) should match all of
|
|
// the fields listed in `ClientDynamicRegistrations.supported`.
|
|
|
|
setTextDocumentDynamicRegistration('synchronization');
|
|
setTextDocumentDynamicRegistration('callHierarchy');
|
|
setTextDocumentDynamicRegistration('completion');
|
|
setTextDocumentDynamicRegistration('hover');
|
|
setTextDocumentDynamicRegistration('inlayHint');
|
|
setTextDocumentDynamicRegistration('inlineValue');
|
|
setTextDocumentDynamicRegistration('signatureHelp');
|
|
setTextDocumentDynamicRegistration('references');
|
|
setTextDocumentDynamicRegistration('documentHighlight');
|
|
setTextDocumentDynamicRegistration('documentSymbol');
|
|
setTextDocumentDynamicRegistration('colorProvider');
|
|
setTextDocumentDynamicRegistration('formatting');
|
|
setTextDocumentDynamicRegistration('onTypeFormatting');
|
|
setTextDocumentDynamicRegistration('rangeFormatting');
|
|
setTextDocumentDynamicRegistration('declaration');
|
|
setTextDocumentDynamicRegistration('definition');
|
|
setTextDocumentDynamicRegistration('implementation');
|
|
setTextDocumentDynamicRegistration('codeAction');
|
|
setTextDocumentDynamicRegistration('rename');
|
|
setTextDocumentDynamicRegistration('foldingRange');
|
|
setTextDocumentDynamicRegistration('selectionRange');
|
|
setTextDocumentDynamicRegistration('semanticTokens');
|
|
setTextDocumentDynamicRegistration('typeDefinition');
|
|
setTextDocumentDynamicRegistration('typeHierarchy');
|
|
}
|
|
|
|
void setAllSupportedWorkspaceDynamicRegistrations() {
|
|
// This list (when combined with the textDocument list) should match all of
|
|
// the fields listed in `ClientDynamicRegistrations.supported`.
|
|
setWorkspaceDynamicRegistration('fileOperations');
|
|
}
|
|
|
|
void setApplyEditSupport([bool supported = true]) {
|
|
workspaceCapabilities = extendWorkspaceCapabilities(workspaceCapabilities, {
|
|
'applyEdit': supported,
|
|
});
|
|
}
|
|
|
|
void setChangeAnnotationSupport([bool supported = true]) {
|
|
workspaceCapabilities = extendWorkspaceCapabilities(workspaceCapabilities, {
|
|
'workspaceEdit': {
|
|
'changeAnnotationSupport':
|
|
supported
|
|
? <String, Object?>{
|
|
// This is set to an empty object to indicate support. We don't
|
|
// currently use any of the child properties.
|
|
}
|
|
: null,
|
|
},
|
|
});
|
|
}
|
|
|
|
void setClientSupportedCommands(List<String>? supportedCommands) {
|
|
experimentalCapabilities['commands'] = supportedCommands;
|
|
}
|
|
|
|
void setCompletionItemDeprecatedFlagSupport() {
|
|
textDocumentCapabilities = extendTextDocumentCapabilities(
|
|
textDocumentCapabilities,
|
|
{
|
|
'completion': {
|
|
'completionItem': {'deprecatedSupport': true},
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
void setCompletionItemInsertReplaceSupport() {
|
|
textDocumentCapabilities = extendTextDocumentCapabilities(
|
|
textDocumentCapabilities,
|
|
{
|
|
'completion': {
|
|
'completionItem': {'insertReplaceSupport': true},
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
void setCompletionItemInsertTextModeSupport() {
|
|
textDocumentCapabilities = extendTextDocumentCapabilities(
|
|
textDocumentCapabilities,
|
|
{
|
|
'completion': {
|
|
'completionItem': {
|
|
'insertTextModeSupport': {
|
|
'valueSet':
|
|
[
|
|
InsertTextMode.adjustIndentation,
|
|
InsertTextMode.asIs,
|
|
].map((k) => k.toJson()).toList(),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
void setCompletionItemKinds(List<CompletionItemKind> kinds) {
|
|
textDocumentCapabilities = extendTextDocumentCapabilities(
|
|
textDocumentCapabilities,
|
|
{
|
|
'completion': {
|
|
'completionItemKind': {
|
|
'valueSet': kinds.map((k) => k.toJson()).toList(),
|
|
},
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
void setCompletionItemLabelDetailsSupport([bool supported = true]) {
|
|
textDocumentCapabilities = extendTextDocumentCapabilities(
|
|
textDocumentCapabilities,
|
|
{
|
|
'completion': {
|
|
'completionItem': {'labelDetailsSupport': supported},
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
void setCompletionItemSnippetSupport([bool supported = true]) {
|
|
textDocumentCapabilities = extendTextDocumentCapabilities(
|
|
textDocumentCapabilities,
|
|
{
|
|
'completion': {
|
|
'completionItem': {'snippetSupport': supported},
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
void setCompletionItemTagSupport(List<CompletionItemTag> tags) {
|
|
textDocumentCapabilities = extendTextDocumentCapabilities(
|
|
textDocumentCapabilities,
|
|
{
|
|
'completion': {
|
|
'completionItem': {
|
|
'tagSupport': {'valueSet': tags.map((k) => k.toJson()).toList()},
|
|
},
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
void setCompletionListDefaults(List<String> defaults) {
|
|
textDocumentCapabilities = extendTextDocumentCapabilities(
|
|
textDocumentCapabilities,
|
|
{
|
|
'completion': {
|
|
'completionList': {'itemDefaults': defaults},
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
void setConfigurationSupport() {
|
|
workspaceCapabilities = extendWorkspaceCapabilities(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,
|
|
{
|
|
'publishDiagnostics': {'codeDescriptionSupport': true},
|
|
},
|
|
);
|
|
}
|
|
|
|
void setDiagnosticTagSupport(List<DiagnosticTag> tags) {
|
|
textDocumentCapabilities = extendTextDocumentCapabilities(
|
|
textDocumentCapabilities,
|
|
{
|
|
'publishDiagnostics': {
|
|
'tagSupport': {'valueSet': tags.map((k) => k.toJson()).toList()},
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
void setDidChangeConfigurationDynamicRegistration() {
|
|
workspaceCapabilities = extendWorkspaceCapabilities(workspaceCapabilities, {
|
|
'didChangeConfiguration': {'dynamicRegistration': true},
|
|
});
|
|
}
|
|
|
|
void setDocumentChangesSupport([bool supported = true]) {
|
|
workspaceCapabilities = extendWorkspaceCapabilities(workspaceCapabilities, {
|
|
'workspaceEdit': {'documentChanges': supported},
|
|
});
|
|
}
|
|
|
|
void setDocumentFormattingDynamicRegistration() {
|
|
setTextDocumentDynamicRegistration('formatting');
|
|
setTextDocumentDynamicRegistration('onTypeFormatting');
|
|
setTextDocumentDynamicRegistration('rangeFormatting');
|
|
}
|
|
|
|
void setDocumentSymbolKinds(List<SymbolKind> kinds) {
|
|
textDocumentCapabilities = extendTextDocumentCapabilities(
|
|
textDocumentCapabilities,
|
|
{
|
|
'documentSymbol': {
|
|
'symbolKind': {'valueSet': kinds.map((k) => k.toJson()).toList()},
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
void setFileCreateSupport([bool supported = true]) {
|
|
if (supported) {
|
|
setDocumentChangesSupport();
|
|
workspaceCapabilities = _withResourceOperationKinds(
|
|
workspaceCapabilities,
|
|
[ResourceOperationKind.Create],
|
|
);
|
|
} else {
|
|
workspaceCapabilities.workspaceEdit?.resourceOperations?.remove(
|
|
ResourceOperationKind.Create,
|
|
);
|
|
}
|
|
}
|
|
|
|
void setFileOperationDynamicRegistration() {
|
|
setWorkspaceDynamicRegistration('fileOperations');
|
|
workspaceCapabilities = extendWorkspaceCapabilities(workspaceCapabilities, {
|
|
'fileOperations': {'dynamicRegistration': true},
|
|
});
|
|
}
|
|
|
|
void setFileRenameSupport([bool supported = true]) {
|
|
if (supported) {
|
|
setDocumentChangesSupport();
|
|
workspaceCapabilities = _withResourceOperationKinds(
|
|
workspaceCapabilities,
|
|
[ResourceOperationKind.Rename],
|
|
);
|
|
} else {
|
|
workspaceCapabilities.workspaceEdit?.resourceOperations?.remove(
|
|
ResourceOperationKind.Rename,
|
|
);
|
|
}
|
|
}
|
|
|
|
void setHierarchicalDocumentSymbolSupport() {
|
|
textDocumentCapabilities = extendTextDocumentCapabilities(
|
|
textDocumentCapabilities,
|
|
{
|
|
'documentSymbol': {'hierarchicalDocumentSymbolSupport': true},
|
|
},
|
|
);
|
|
}
|
|
|
|
void setHoverContentFormat(List<MarkupKind> formats) {
|
|
textDocumentCapabilities = extendTextDocumentCapabilities(
|
|
textDocumentCapabilities,
|
|
{
|
|
'hover': {'contentFormat': formats.map((k) => k.toJson()).toList()},
|
|
},
|
|
);
|
|
}
|
|
|
|
void setHoverDynamicRegistration() {
|
|
setTextDocumentDynamicRegistration('hover');
|
|
}
|
|
|
|
void setLineFoldingOnly() {
|
|
textDocumentCapabilities = extendTextDocumentCapabilities(
|
|
textDocumentCapabilities,
|
|
{
|
|
'foldingRange': {'lineFoldingOnly': true},
|
|
},
|
|
);
|
|
}
|
|
|
|
void setLocationLinkSupport([bool supported = true]) {
|
|
textDocumentCapabilities = extendTextDocumentCapabilities(
|
|
textDocumentCapabilities,
|
|
{
|
|
'definition': {'linkSupport': supported},
|
|
'typeDefinition': {'linkSupport': supported},
|
|
'implementation': {'linkSupport': supported},
|
|
},
|
|
);
|
|
}
|
|
|
|
void setSignatureHelpContentFormat(List<MarkupKind>? formats) {
|
|
textDocumentCapabilities = extendTextDocumentCapabilities(
|
|
textDocumentCapabilities,
|
|
{
|
|
'signatureHelp': {
|
|
'signatureInformation': {
|
|
'documentationFormat': formats?.map((k) => k.toJson()).toList(),
|
|
},
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
void setSnippetTextEditSupport([bool supported = true]) {
|
|
experimentalCapabilities['snippetTextEdit'] = supported;
|
|
}
|
|
|
|
void setSupportedCodeActionKinds(List<CodeActionKind>? kinds) {
|
|
textDocumentCapabilities = extendTextDocumentCapabilities(
|
|
textDocumentCapabilities,
|
|
{
|
|
'codeAction': {
|
|
'codeActionLiteralSupport':
|
|
kinds != null
|
|
? {
|
|
'codeActionKind': {
|
|
'valueSet': kinds.map((k) => k.toJson()).toList(),
|
|
},
|
|
}
|
|
: null,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
void setSupportedCommandParameterKinds(Set<String>? kinds) {
|
|
experimentalCapabilities['dartCodeAction'] = {
|
|
'commandParameterSupport': {'supportedKinds': kinds?.toList()},
|
|
};
|
|
}
|
|
|
|
void setTextDocumentDynamicRegistration(String name) {
|
|
var json =
|
|
name == 'semanticTokens'
|
|
? SemanticTokensClientCapabilities(
|
|
dynamicRegistration: true,
|
|
requests: ClientSemanticTokensRequestOptions(),
|
|
formats: [],
|
|
tokenModifiers: [],
|
|
tokenTypes: [],
|
|
).toJson()
|
|
: {'dynamicRegistration': true};
|
|
textDocumentCapabilities = extendTextDocumentCapabilities(
|
|
textDocumentCapabilities,
|
|
{name: json},
|
|
);
|
|
}
|
|
|
|
void setTextSyncDynamicRegistration() {
|
|
setTextDocumentDynamicRegistration('synchronization');
|
|
}
|
|
|
|
void setWorkDoneProgressSupport() {
|
|
windowCapabilities = extendWindowCapabilities(windowCapabilities, {
|
|
'workDoneProgress': true,
|
|
});
|
|
}
|
|
|
|
void setWorkspaceDynamicRegistration(String name) {
|
|
workspaceCapabilities = extendWorkspaceCapabilities(workspaceCapabilities, {
|
|
name: {'dynamicRegistration': true},
|
|
});
|
|
}
|
|
|
|
WorkspaceClientCapabilities _withResourceOperationKinds(
|
|
WorkspaceClientCapabilities source,
|
|
List<ResourceOperationKind> kinds,
|
|
) {
|
|
return extendWorkspaceCapabilities(source, {
|
|
'workspaceEdit': {
|
|
'documentChanges':
|
|
true, // docChanges aren't included in resourceOperations
|
|
'resourceOperations': kinds.map((k) => k.toJson()).toList(),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
mixin LspAnalysisServerTestMixin on LspRequestHelpersMixin, LspEditHelpersMixin
|
|
implements ClientCapabilitiesHelperMixin {
|
|
/// A progress token used in tests where the client-provides the token, which
|
|
/// should not be validated as being created by the server first.
|
|
final clientProvidedTestWorkDoneToken = ProgressToken.t2('client-test');
|
|
|
|
late String projectFolderPath,
|
|
mainFilePath,
|
|
nonExistentFilePath,
|
|
pubspecFilePath,
|
|
analysisOptionsPath;
|
|
|
|
final String simplePubspecContent = 'name: my_project';
|
|
|
|
/// The client capabilities sent to the server during initialization.
|
|
///
|
|
/// null if an initialization request has not yet been sent.
|
|
ClientCapabilities? _clientCapabilities;
|
|
|
|
/// The capabilities returned from the server during initialization.
|
|
///
|
|
/// `null` if the server is not initialized, or returned an error during
|
|
/// initialize.
|
|
ServerCapabilities? _serverCapabilities;
|
|
|
|
final validProgressTokens = <ProgressToken>{};
|
|
|
|
/// Default initialization options to be used if [initialize] is not provided
|
|
/// options explicitly.
|
|
Map<String, Object?>? defaultInitializationOptions;
|
|
|
|
/// The current state of all diagnostics from the server.
|
|
///
|
|
/// 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 = <Uri, List<Diagnostic>>{};
|
|
|
|
/// Whether to fail tests if any error notifications are received from the
|
|
/// server.
|
|
///
|
|
/// This does not need to be set when using [expectErrorNotification].
|
|
bool failTestOnAnyErrorNotification = true;
|
|
|
|
/// Whether to fail tests if any error diagnostics are received from the
|
|
/// server.
|
|
bool failTestOnErrorDiagnostic = true;
|
|
|
|
/// A completer for [initialAnalysis].
|
|
final Completer<void> _initialAnalysisCompleter = Completer<void>();
|
|
|
|
/// A completer for [currentAnalysis].
|
|
Completer<void> _currentAnalysisCompleter = Completer<void>()..complete();
|
|
|
|
/// [analysisOptionsPath] as a 'file:///' [Uri].
|
|
Uri get analysisOptionsUri => pathContext.toUri(analysisOptionsPath);
|
|
|
|
/// A [Future] that completes when the current analysis completes (or is
|
|
/// already completed if no analysis is in progress).
|
|
Future<void> get currentAnalysis => _currentAnalysisCompleter.future;
|
|
|
|
/// 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 => _initialAnalysisCompleter.future;
|
|
|
|
bool get initialized => _clientCapabilities != null;
|
|
|
|
/// The URI for an augmentation for [mainFileUri].
|
|
Uri get mainFileAugmentationUri => mainFileUri.replace(
|
|
path: mainFileUri.path.replaceFirst('.dart', '_augmentation.dart'),
|
|
);
|
|
|
|
/// The URI for the macro-generated contents for [mainFileUri].
|
|
Uri get mainFileMacroUri => mainFileUri.replace(scheme: macroClientUriScheme);
|
|
|
|
/// [mainFilePath] as a 'file:///' [Uri].
|
|
Uri get mainFileUri => pathContext.toUri(mainFilePath);
|
|
|
|
/// [nonExistentFilePath] as a 'file:///' [Uri].
|
|
Uri get nonExistentFileUri => pathContext.toUri(nonExistentFilePath);
|
|
|
|
/// A stream of [NotificationMessage]s from the server.
|
|
@override
|
|
Stream<NotificationMessage> get notificationsFromServer {
|
|
return serverToClient
|
|
.where((m) => m is NotificationMessage)
|
|
.cast<NotificationMessage>();
|
|
}
|
|
|
|
/// A stream of [OpenUriParams] for any `dart/openUri` notifications.
|
|
Stream<OpenUriParams> get openUriNotifications => notificationsFromServer
|
|
.where((notification) => notification.method == CustomMethods.openUri)
|
|
.map(
|
|
(message) =>
|
|
OpenUriParams.fromJson(message.params as Map<String, Object?>),
|
|
);
|
|
|
|
path.Context get pathContext;
|
|
|
|
/// [projectFolderPath] as a 'file:///' [Uri].
|
|
Uri get projectFolderUri => pathContext.toUri(projectFolderPath);
|
|
|
|
/// A stream of diagnostic notifications from the server.
|
|
Stream<PublishDiagnosticsParams> get publishedDiagnostics {
|
|
return notificationsFromServer
|
|
.where(
|
|
(notification) =>
|
|
notification.method == Method.textDocument_publishDiagnostics,
|
|
)
|
|
.map(
|
|
(notification) => PublishDiagnosticsParams.fromJson(
|
|
notification.params as Map<String, Object?>,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// [pubspecFilePath] as a 'file:///' [Uri].
|
|
Uri get pubspecFileUri => pathContext.toUri(pubspecFilePath);
|
|
|
|
/// A stream of [RequestMessage]s from the server.
|
|
@override
|
|
Stream<RequestMessage> get requestsFromServer {
|
|
return serverToClient
|
|
.where((m) => m is RequestMessage)
|
|
.cast<RequestMessage>();
|
|
}
|
|
|
|
/// The capabilities returned from the server during initialization.
|
|
ServerCapabilities get serverCapabilities => _serverCapabilities!;
|
|
|
|
Stream<Message> get serverToClient;
|
|
|
|
/// A stream of [ShowMessageParams] for any `window/logMessage` notifications.
|
|
Stream<ShowMessageParams> get showMessageNotifications =>
|
|
notificationsFromServer
|
|
.where(
|
|
(notification) => notification.method == Method.window_showMessage,
|
|
)
|
|
.map(
|
|
(message) => ShowMessageParams.fromJson(
|
|
message.params as Map<String, Object?>,
|
|
),
|
|
);
|
|
|
|
String get testPackageRootPath => projectFolderPath;
|
|
|
|
Future<void> changeFile(
|
|
int newVersion,
|
|
Uri uri,
|
|
List<TextDocumentContentChangeEvent> changes,
|
|
) async {
|
|
var notification = makeNotification(
|
|
Method.textDocument_didChange,
|
|
DidChangeTextDocumentParams(
|
|
textDocument: VersionedTextDocumentIdentifier(
|
|
version: newVersion,
|
|
uri: uri,
|
|
),
|
|
contentChanges: changes,
|
|
),
|
|
);
|
|
await sendNotificationToServer(notification);
|
|
}
|
|
|
|
Future<void> changeWorkspaceFolders({
|
|
List<Uri>? add,
|
|
List<Uri>? remove,
|
|
}) async {
|
|
var notification = makeNotification(
|
|
Method.workspace_didChangeWorkspaceFolders,
|
|
DidChangeWorkspaceFoldersParams(
|
|
event: WorkspaceFoldersChangeEvent(
|
|
added: add?.map(toWorkspaceFolder).toList() ?? const [],
|
|
removed: remove?.map(toWorkspaceFolder).toList() ?? const [],
|
|
),
|
|
),
|
|
);
|
|
await sendNotificationToServer(notification);
|
|
}
|
|
|
|
Future<void> closeFile(Uri uri) async {
|
|
var notification = makeNotification(
|
|
Method.textDocument_didClose,
|
|
DidCloseTextDocumentParams(
|
|
textDocument: TextDocumentIdentifier(uri: uri),
|
|
),
|
|
);
|
|
await sendNotificationToServer(notification);
|
|
}
|
|
|
|
Future<Object?> executeCodeAction(
|
|
Either2<CodeActionLiteral, Command> codeAction,
|
|
) {
|
|
var command = codeAction.map(
|
|
(codeAction) => codeAction.command!,
|
|
(command) => command,
|
|
);
|
|
return executeCommand(command);
|
|
}
|
|
|
|
Future<T> executeCommand<T>(
|
|
Command command, {
|
|
T Function(Map<String, Object?>)? decoder,
|
|
ProgressToken? workDoneToken,
|
|
}) {
|
|
var supportedCommands =
|
|
_serverCapabilities?.executeCommandProvider?.commands ?? [];
|
|
if (!supportedCommands.contains(command.command)) {
|
|
throw ArgumentError(
|
|
'Server does not support ${command.command}. '
|
|
'Is it missing from serverSupportedCommands?',
|
|
);
|
|
}
|
|
var request = makeRequest(
|
|
Method.workspace_executeCommand,
|
|
ExecuteCommandParams(
|
|
command: command.command,
|
|
arguments: command.arguments,
|
|
workDoneToken: workDoneToken,
|
|
),
|
|
);
|
|
return expectSuccessfulResponseTo<T, Map<String, Object?>>(
|
|
request,
|
|
decoder ?? (result) => result as T,
|
|
);
|
|
}
|
|
|
|
Future<ShowMessageParams> expectErrorNotification(
|
|
FutureOr<void> Function() f, {
|
|
Duration timeout = const Duration(seconds: 5),
|
|
}) async {
|
|
var firstError = errorNotificationsFromServer.first;
|
|
|
|
failTestOnAnyErrorNotification = false;
|
|
|
|
await f();
|
|
var notificationFromServer = await firstError.timeout(timeout);
|
|
|
|
failTestOnAnyErrorNotification = true;
|
|
|
|
expect(notificationFromServer, isNotNull);
|
|
return ShowMessageParams.fromJson(
|
|
notificationFromServer.params as Map<String, Object?>,
|
|
);
|
|
}
|
|
|
|
Future<T> expectNotification<T>(
|
|
bool Function(NotificationMessage) test,
|
|
FutureOr<void> Function() f, {
|
|
Duration timeout = const Duration(seconds: 5),
|
|
}) async {
|
|
var firstError = notificationsFromServer.firstWhere(test);
|
|
await f();
|
|
|
|
var notificationFromServer = await firstError.timeout(timeout);
|
|
|
|
expect(notificationFromServer, isNotNull);
|
|
return notificationFromServer.params as T;
|
|
}
|
|
|
|
/// Gets the current contents of a file.
|
|
///
|
|
/// This is used to apply edits when the server sends workspace/applyEdit. It
|
|
/// should reflect the content that the client would have in this case, which
|
|
/// would be an overlay (if the file is open) or the underlying file.
|
|
String? getCurrentFileContent(Uri uri);
|
|
|
|
/// A helper that initializes the server with common values, since the server
|
|
/// will reject any other requests until it is initialized.
|
|
/// Capabilities are overridden by providing JSON to avoid having to construct
|
|
/// full objects just to change one value (the types are immutable) so must
|
|
/// match the spec exactly and are not verified.
|
|
Future<ResponseMessage> initialize({
|
|
String? rootPath,
|
|
Uri? rootUri,
|
|
List<Uri>? workspaceFolders,
|
|
Map<String, Object?>? experimentalCapabilities,
|
|
Map<String, Object?>? initializationOptions,
|
|
bool throwOnFailure = true,
|
|
bool allowEmptyRootUri = false,
|
|
bool includeClientRequestTime = false,
|
|
void Function()? immediatelyAfterInitialized,
|
|
}) async {
|
|
this.includeClientRequestTime = includeClientRequestTime;
|
|
|
|
errorNotificationsFromServer.listen((NotificationMessage error) {
|
|
// Always subscribe to this and check the flag here so it can be toggled
|
|
// during tests (for example automatically by expectErrorNotification).
|
|
if (failTestOnAnyErrorNotification) {
|
|
fail('${error.toJson()}');
|
|
}
|
|
});
|
|
|
|
publishedDiagnostics.listen((diagnostics) {
|
|
if (failTestOnErrorDiagnostic &&
|
|
diagnostics.diagnostics.any(
|
|
(diagnostic) => diagnostic.severity == DiagnosticSeverity.Error,
|
|
)) {
|
|
fail('Unexpected diagnostics: ${diagnostics.toJson()}');
|
|
}
|
|
});
|
|
|
|
var clientCapabilities = ClientCapabilities(
|
|
workspace: workspaceCapabilities,
|
|
textDocument: textDocumentCapabilities,
|
|
window: windowCapabilities,
|
|
experimental: experimentalCapabilities ?? this.experimentalCapabilities,
|
|
);
|
|
_clientCapabilities = clientCapabilities;
|
|
|
|
// Handle any standard incoming requests that aren't test-specific, for example
|
|
// accepting requests to create progress tokens.
|
|
requestsFromServer.listen((request) async {
|
|
if (request.method == Method.window_workDoneProgress_create) {
|
|
respondTo(request, await _handleWorkDoneProgressCreate(request));
|
|
}
|
|
});
|
|
|
|
notificationsFromServer.listen((notification) async {
|
|
if (notification.method == Method.progress) {
|
|
await _handleProgress(notification);
|
|
} else if (notification.method == CustomMethods.analyzerStatus) {
|
|
var params = AnalyzerStatusParams.fromJson(
|
|
notification.params as Map<String, Object?>,
|
|
);
|
|
|
|
if (params.isAnalyzing) {
|
|
_handleAnalysisBegin();
|
|
} else {
|
|
_handleAnalysisEnd();
|
|
}
|
|
}
|
|
});
|
|
|
|
// Track diagnostics from the server so tests can easily access the current
|
|
// state.
|
|
trackDiagnostics(diagnostics);
|
|
|
|
// Assume if none of the project options were set, that we want to default to
|
|
// opening the test project folder.
|
|
if (rootPath == null &&
|
|
rootUri == null &&
|
|
workspaceFolders == null &&
|
|
!allowEmptyRootUri) {
|
|
rootUri = pathContext.toUri(projectFolderPath);
|
|
}
|
|
var request = makeRequest(
|
|
Method.initialize,
|
|
InitializeParams(
|
|
rootPath: rootPath,
|
|
rootUri: rootUri,
|
|
initializationOptions:
|
|
initializationOptions ?? defaultInitializationOptions,
|
|
capabilities: clientCapabilities,
|
|
workspaceFolders: workspaceFolders?.map(toWorkspaceFolder).toList(),
|
|
),
|
|
);
|
|
var response = await sendRequestToServer(request);
|
|
expect(response.id, equals(request.id));
|
|
|
|
var error = response.error;
|
|
if (error == null) {
|
|
var result = InitializeResult.fromJson(
|
|
response.result as Map<String, Object?>,
|
|
);
|
|
_serverCapabilities = result.capabilities;
|
|
|
|
var notification = makeNotification(
|
|
Method.initialized,
|
|
InitializedParams(),
|
|
);
|
|
|
|
var initializedNotification = sendNotificationToServer(notification);
|
|
immediatelyAfterInitialized?.call();
|
|
await initializedNotification;
|
|
await pumpEventQueue();
|
|
} else if (throwOnFailure) {
|
|
throw 'Error during initialize request: '
|
|
'${error.code}: ${error.message}';
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
NotificationMessage makeNotification(Method method, ToJsonable? params) {
|
|
return NotificationMessage(
|
|
method: method,
|
|
params: params,
|
|
jsonrpc: jsonRpcVersion,
|
|
clientRequestTime:
|
|
includeClientRequestTime
|
|
? DateTime.now().millisecondsSinceEpoch
|
|
: null,
|
|
);
|
|
}
|
|
|
|
RequestMessage makeRenameRequest(
|
|
int? version,
|
|
Uri uri,
|
|
Position pos,
|
|
String newName,
|
|
) {
|
|
var docIdentifier =
|
|
version != null
|
|
? VersionedTextDocumentIdentifier(version: version, uri: uri)
|
|
: TextDocumentIdentifier(uri: uri);
|
|
var request = makeRequest(
|
|
Method.textDocument_rename,
|
|
RenameParams(
|
|
newName: newName,
|
|
textDocument: docIdentifier,
|
|
position: pos,
|
|
),
|
|
);
|
|
return request;
|
|
}
|
|
|
|
/// Watches for `client/registerCapability` requests and updates
|
|
/// `registrations`.
|
|
Future<T> monitorDynamicRegistrations<T>(
|
|
List<Registration> registrations,
|
|
Future<T> Function() f,
|
|
) {
|
|
return handleExpectedRequest<T, RegistrationParams, void>(
|
|
Method.client_registerCapability,
|
|
RegistrationParams.fromJson,
|
|
f,
|
|
handler: (registrationParams) {
|
|
registrations.addAll(registrationParams.registrations);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Expects both unregistration and reregistration.
|
|
Future<T> monitorDynamicReregistration<T>(
|
|
List<Registration> registrations,
|
|
Future<T> Function() f,
|
|
) => monitorDynamicUnregistrations(
|
|
registrations,
|
|
() => monitorDynamicRegistrations(registrations, f),
|
|
);
|
|
|
|
/// Watches for `client/unregisterCapability` requests and updates
|
|
/// `registrations`.
|
|
Future<T> monitorDynamicUnregistrations<T>(
|
|
List<Registration> registrations,
|
|
Future<T> Function() f,
|
|
) {
|
|
return handleExpectedRequest<T, UnregistrationParams, void>(
|
|
Method.client_unregisterCapability,
|
|
UnregistrationParams.fromJson,
|
|
f,
|
|
handler: (unregistrationParams) {
|
|
registrations.removeWhere(
|
|
(element) => unregistrationParams.unregisterations.any(
|
|
(u) => u.id == element.id,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> openFile(Uri uri, String content, {int version = 1}) async {
|
|
var notification = makeNotification(
|
|
Method.textDocument_didOpen,
|
|
DidOpenTextDocumentParams(
|
|
textDocument: TextDocumentItem(
|
|
uri: uri,
|
|
languageId: dartLanguageId,
|
|
version: version,
|
|
text: content,
|
|
),
|
|
),
|
|
);
|
|
await sendNotificationToServer(notification);
|
|
await pumpEventQueue(times: 128);
|
|
}
|
|
|
|
int positionCompare(Position p1, Position p2) {
|
|
if (p1.line < p2.line) return -1;
|
|
if (p1.line > p2.line) return 1;
|
|
|
|
if (p1.character < p2.character) return -1;
|
|
if (p1.character > p2.character) return 1;
|
|
|
|
return 0;
|
|
}
|
|
|
|
/// Calls the supplied function and responds to any `workspace/configuration`
|
|
/// request with the supplied config.
|
|
///
|
|
/// Automatically enables `workspace/configuration` support.
|
|
Future<T> provideConfig<T>(
|
|
Future<T> Function() f,
|
|
FutureOr<Map<String, Object?>> globalConfig, {
|
|
FutureOr<Map<String, Map<String, Object?>>>? folderConfig,
|
|
}) {
|
|
var self = this;
|
|
if (self is AbstractLspAnalysisServerTest) {
|
|
self.setConfigurationSupport();
|
|
}
|
|
return handleExpectedRequest<
|
|
T,
|
|
ConfigurationParams,
|
|
List<Map<String, Object?>>
|
|
>(
|
|
Method.workspace_configuration,
|
|
ConfigurationParams.fromJson,
|
|
f,
|
|
handler: (configurationParams) async {
|
|
// We must respond to the request for config with items that match the
|
|
// request. For any item in the request without a folder, we will return
|
|
// the global config. For any item in the request with a folder we will
|
|
// return the config for that item in the map, or fall back to the global
|
|
// config if it does not exist.
|
|
var global = await globalConfig;
|
|
var folders = await folderConfig;
|
|
return configurationParams.items.map((requestedConfig) {
|
|
var uri = requestedConfig.scopeUri;
|
|
var path = uri != null ? pathContext.fromUri(uri) : null;
|
|
// Use the config the test provided for this path, or fall back to
|
|
// global.
|
|
return (folders != null ? folders[path] : null) ?? global;
|
|
}).toList();
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Returns the range of [pattern] in [code].
|
|
Range rangeOfPattern(TestCode code, Pattern pattern) {
|
|
var content = code.code;
|
|
var match = pattern.allMatches(content).first;
|
|
return Range(
|
|
start: positionFromOffset(match.start, content),
|
|
end: positionFromOffset(match.end, content),
|
|
);
|
|
}
|
|
|
|
/// Returns the range of [searchText] in [code].
|
|
Range rangeOfString(TestCode code, String searchText) =>
|
|
rangeOfPattern(code, searchText);
|
|
|
|
/// Returns the range of [searchText] in [content].
|
|
Range rangeOfStringInString(String content, String searchText) {
|
|
var 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(
|
|
start: positionFromOffset(0, content),
|
|
end: positionFromOffset(content.length, content),
|
|
);
|
|
}
|
|
|
|
/// Gets the range in [content] that beings with the string [prefix] and
|
|
/// has a length matching [text].
|
|
Range rangeStartingAtString(String content, String prefix, String text) {
|
|
var offset = content.indexOf(prefix);
|
|
var end = offset + text.length;
|
|
return Range(
|
|
start: positionFromOffset(offset, content),
|
|
end: positionFromOffset(end, content),
|
|
);
|
|
}
|
|
|
|
Future<WorkspaceEdit?> rename(
|
|
Uri uri,
|
|
int? version,
|
|
Position pos,
|
|
String newName,
|
|
) {
|
|
var request = makeRenameRequest(version, uri, pos, newName);
|
|
return expectSuccessfulResponseTo(request, WorkspaceEdit.fromJson);
|
|
}
|
|
|
|
Future<ResponseMessage> renameRaw(
|
|
Uri uri,
|
|
int version,
|
|
Position pos,
|
|
String newName,
|
|
) {
|
|
var request = makeRenameRequest(version, uri, pos, newName);
|
|
return sendRequestToServer(request);
|
|
}
|
|
|
|
Future<void> replaceFile(int newVersion, Uri uri, String content) {
|
|
return changeFile(newVersion, uri, [
|
|
TextDocumentContentChangeEvent.t2(
|
|
TextDocumentContentChangeWholeDocument(text: content),
|
|
),
|
|
]);
|
|
}
|
|
|
|
Future<ResponseMessage> sendDidChangeConfiguration() {
|
|
var request = makeRequest(
|
|
Method.workspace_didChangeConfiguration,
|
|
DidChangeConfigurationParams(),
|
|
);
|
|
return sendRequestToServer(request);
|
|
}
|
|
|
|
void sendExit() {
|
|
var request = makeRequest(Method.exit, null);
|
|
sendRequestToServer(request);
|
|
}
|
|
|
|
FutureOr<void> sendNotificationToServer(NotificationMessage notification);
|
|
|
|
Future<ResponseMessage> sendRequestToServer(RequestMessage request);
|
|
|
|
// This is the signature expected for LSP.
|
|
// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#:~:text=Response%3A-,result%3A%20null,-error%3A%20code%20and
|
|
Future<Null> sendShutdown() {
|
|
var request = makeRequest(Method.shutdown, null);
|
|
return expectSuccessfulResponseTo(request, (result) => result as Null);
|
|
}
|
|
|
|
/// Creates a [TextEdit] using the `insert` range of a [InsertReplaceEdit].
|
|
TextEdit textEditForInsert(Either2<InsertReplaceEdit, TextEdit> edit) =>
|
|
edit.map(
|
|
(e) => TextEdit(range: e.insert, newText: e.newText),
|
|
(_) => throw 'Expected InsertReplaceEdit, got TextEdit',
|
|
);
|
|
|
|
/// Creates a [TextEdit] using the `replace` range of a [InsertReplaceEdit].
|
|
TextEdit textEditForReplace(Either2<InsertReplaceEdit, TextEdit> edit) =>
|
|
edit.map(
|
|
(e) => TextEdit(range: e.replace, newText: e.newText),
|
|
(_) => throw 'Expected InsertReplaceEdit, got TextEdit',
|
|
);
|
|
|
|
TextEdit toTextEdit(Either2<InsertReplaceEdit, TextEdit> edit) => edit.map(
|
|
(_) => throw 'Expected TextEdit, got InsertReplaceEdit',
|
|
(e) => e,
|
|
);
|
|
|
|
WorkspaceFolder toWorkspaceFolder(Uri uri) {
|
|
return WorkspaceFolder(uri: uri, name: path.basename(uri.path));
|
|
}
|
|
|
|
/// Records the latest diagnostics for each file in [latestDiagnostics].
|
|
///
|
|
/// [latestDiagnostics] maps from a URI to the set of current diagnostics.
|
|
StreamSubscription<PublishDiagnosticsParams> trackDiagnostics(
|
|
Map<Uri, List<Diagnostic>> latestDiagnostics,
|
|
) {
|
|
return publishedDiagnostics.listen((diagnostics) {
|
|
latestDiagnostics[diagnostics.uri] = diagnostics.diagnostics;
|
|
});
|
|
}
|
|
|
|
/// Tells the server the config has changed, and provides the supplied config
|
|
/// when it requests the updated config.
|
|
Future<ResponseMessage> updateConfig(Map<String, dynamic> config) {
|
|
return provideConfig(sendDidChangeConfiguration, config);
|
|
}
|
|
|
|
Future<void> waitForAnalysisComplete() => waitForAnalysisStatus(false);
|
|
|
|
Future<void> waitForAnalysisStart() => waitForAnalysisStatus(true);
|
|
|
|
Future<void> waitForAnalysisStatus(bool analyzing) async {
|
|
await notificationsFromServer.firstWhere((message) {
|
|
if (message.method == CustomMethods.analyzerStatus) {
|
|
if (_clientCapabilities!.window?.workDoneProgress == true) {
|
|
throw Exception(
|
|
'Received ${CustomMethods.analyzerStatus} notification '
|
|
'but client supports workDoneProgress',
|
|
);
|
|
}
|
|
|
|
var params = AnalyzerStatusParams.fromJson(
|
|
message.params as Map<String, Object?>,
|
|
);
|
|
return params.isAnalyzing == analyzing;
|
|
} else if (message.method == Method.progress) {
|
|
if (_clientCapabilities!.window?.workDoneProgress != true) {
|
|
throw Exception(
|
|
'Received ${CustomMethods.analyzerStatus} notification '
|
|
'but client supports workDoneProgress',
|
|
);
|
|
}
|
|
|
|
var params = ProgressParams.fromJson(
|
|
message.params as Map<String, Object?>,
|
|
);
|
|
|
|
// Skip unrelated progress notifications.
|
|
if (params.token != analyzingProgressToken) {
|
|
return false;
|
|
}
|
|
|
|
if (params.value is Map<String, dynamic>) {
|
|
var isDesiredStatusMessage =
|
|
analyzing
|
|
? WorkDoneProgressBegin.canParse(
|
|
params.value,
|
|
nullLspJsonReporter,
|
|
)
|
|
: WorkDoneProgressEnd.canParse(
|
|
params.value,
|
|
nullLspJsonReporter,
|
|
);
|
|
|
|
return isDesiredStatusMessage;
|
|
} else {
|
|
throw Exception('\$/progress params value was not valid');
|
|
}
|
|
}
|
|
// Message is not what we're waiting for.
|
|
return false;
|
|
});
|
|
}
|
|
|
|
Future<List<ClosingLabel>> waitForClosingLabels(Uri uri) async {
|
|
late PublishClosingLabelsParams closingLabelsParams;
|
|
await notificationsFromServer.firstWhere((message) {
|
|
if (message.method == CustomMethods.publishClosingLabels) {
|
|
closingLabelsParams = PublishClosingLabelsParams.fromJson(
|
|
message.params as Map<String, Object?>,
|
|
);
|
|
|
|
return closingLabelsParams.uri == uri;
|
|
}
|
|
return false;
|
|
});
|
|
return closingLabelsParams.labels;
|
|
}
|
|
|
|
Future<List<Diagnostic>?> waitForDiagnostics(Uri uri) {
|
|
return publishedDiagnostics
|
|
.where((params) => params.uri == uri)
|
|
.map<List<Diagnostic>?>((params) => params.diagnostics)
|
|
.firstWhere((_) => true, orElse: () => null);
|
|
}
|
|
|
|
Future<FlutterOutline> waitForFlutterOutline(Uri uri) async {
|
|
late PublishFlutterOutlineParams outlineParams;
|
|
await notificationsFromServer.firstWhere((message) {
|
|
if (message.method == CustomMethods.publishFlutterOutline) {
|
|
outlineParams = PublishFlutterOutlineParams.fromJson(
|
|
message.params as Map<String, Object?>,
|
|
);
|
|
|
|
return outlineParams.uri == uri;
|
|
}
|
|
return false;
|
|
});
|
|
return outlineParams.outline;
|
|
}
|
|
|
|
Future<Outline> waitForOutline(Uri uri) async {
|
|
late PublishOutlineParams outlineParams;
|
|
await notificationsFromServer.firstWhere((message) {
|
|
if (message.method == CustomMethods.publishOutline) {
|
|
outlineParams = PublishOutlineParams.fromJson(
|
|
message.params as Map<String, Object?>,
|
|
);
|
|
|
|
return outlineParams.uri == uri;
|
|
}
|
|
return false;
|
|
});
|
|
return outlineParams.outline;
|
|
}
|
|
|
|
void _handleAnalysisBegin() async {
|
|
assert(_currentAnalysisCompleter.isCompleted);
|
|
_currentAnalysisCompleter = Completer<void>();
|
|
}
|
|
|
|
void _handleAnalysisEnd() async {
|
|
if (!_initialAnalysisCompleter.isCompleted) {
|
|
_initialAnalysisCompleter.complete();
|
|
}
|
|
assert(!_currentAnalysisCompleter.isCompleted);
|
|
_currentAnalysisCompleter.complete();
|
|
}
|
|
|
|
Future<void> _handleProgress(NotificationMessage request) async {
|
|
var params = ProgressParams.fromJson(
|
|
request.params as Map<String, Object?>,
|
|
);
|
|
if (params.token != clientProvidedTestWorkDoneToken &&
|
|
!validProgressTokens.contains(params.token)) {
|
|
throw Exception(
|
|
'Server sent a progress notification for a token '
|
|
'that has not been created: ${params.token}',
|
|
);
|
|
}
|
|
|
|
if (WorkDoneProgressEnd.canParse(params.value, nullLspJsonReporter)) {
|
|
validProgressTokens.remove(params.token);
|
|
}
|
|
|
|
if (params.token == analyzingProgressToken) {
|
|
if (WorkDoneProgressBegin.canParse(params.value, nullLspJsonReporter)) {
|
|
_handleAnalysisBegin();
|
|
}
|
|
if (WorkDoneProgressEnd.canParse(params.value, nullLspJsonReporter)) {
|
|
_handleAnalysisEnd();
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _handleWorkDoneProgressCreate(RequestMessage request) async {
|
|
if (_clientCapabilities!.window?.workDoneProgress != true) {
|
|
throw Exception(
|
|
'Server sent ${Method.window_workDoneProgress_create} '
|
|
'but client capabilities do not allow',
|
|
);
|
|
}
|
|
var params = WorkDoneProgressCreateParams.fromJson(
|
|
request.params as Map<String, Object?>,
|
|
);
|
|
if (validProgressTokens.contains(params.token)) {
|
|
throw Exception('Server tried to create already-active progress token');
|
|
}
|
|
validProgressTokens.add(params.token);
|
|
}
|
|
|
|
/// Checks whether a notification is likely an error from the server (for
|
|
/// example a window/showMessage). This is useful for tests that want to
|
|
/// ensure no errors come from the server in response to notifications (which
|
|
/// don't have their own responses).
|
|
bool _isErrorNotification(NotificationMessage notification) {
|
|
var method = notification.method;
|
|
var params = notification.params as Map<String, Object?>?;
|
|
if (method == Method.window_logMessage && params != null) {
|
|
return LogMessageParams.fromJson(params).type == MessageType.Error;
|
|
} else if (method == Method.window_showMessage && params != null) {
|
|
return ShowMessageParams.fromJson(params).type == MessageType.Error;
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// An [AbstractLspAnalysisServerTest] that provides an implementation of
|
|
/// [SharedTestInterface] to allow tests to be shared between server/test kinds.
|
|
abstract class SharedAbstractLspAnalysisServerTest
|
|
extends AbstractLspAnalysisServerTest
|
|
implements SharedTestInterface {
|
|
@override
|
|
String get testFilePath => join(projectFolderPath, 'lib', 'test.dart');
|
|
|
|
@override
|
|
Uri get testFileUri => toUri(testFilePath);
|
|
|
|
@override
|
|
void createFile(String path, String content) {
|
|
newFile(path, content);
|
|
}
|
|
|
|
@override
|
|
Future<void> initializeServer() async {
|
|
await initialize();
|
|
await currentAnalysis;
|
|
}
|
|
}
|