44275c6001
Change-Id: Id37a1954c71fb10c4968c5af6a873ad0ef864a5a Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/191403 Reviewed-by: Brian Wilkerson <brianwilkerson@google.com> Commit-Queue: Brian Wilkerson <brianwilkerson@google.com>
1757 lines
59 KiB
Dart
1757 lines
59 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_custom_generated.dart';
|
|
import 'package:analysis_server/lsp_protocol/protocol_generated.dart';
|
|
import 'package:analysis_server/lsp_protocol/protocol_special.dart';
|
|
import 'package:analysis_server/src/analysis_server.dart';
|
|
import 'package:analysis_server/src/lsp/constants.dart';
|
|
import 'package:analysis_server/src/lsp/json_parsing.dart';
|
|
import 'package:analysis_server/src/lsp/lsp_analysis_server.dart';
|
|
import 'package:analysis_server/src/lsp/mapping.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/utilities/mocks.dart';
|
|
import 'package:analyzer/instrumentation/instrumentation.dart';
|
|
import 'package:analyzer/source/line_info.dart';
|
|
import 'package:analyzer/src/dart/analysis/experiments.dart';
|
|
import 'package:analyzer/src/generated/sdk.dart';
|
|
import 'package:analyzer/src/test_utilities/mock_sdk.dart';
|
|
import 'package:analyzer/src/test_utilities/package_config_file_builder.dart';
|
|
import 'package:analyzer/src/test_utilities/resource_provider_mixin.dart';
|
|
import 'package:analyzer_plugin/protocol/protocol.dart' as plugin;
|
|
import 'package:analyzer_plugin/src/protocol/protocol_internal.dart' as plugin;
|
|
import 'package:meta/meta.dart';
|
|
import 'package:path/path.dart' as path;
|
|
import 'package:test/test.dart' hide expect;
|
|
import 'package:test/test.dart' as test show expect;
|
|
|
|
import '../mocks.dart';
|
|
import '../src/utilities/mock_packages.dart';
|
|
|
|
const dartLanguageId = 'dart';
|
|
|
|
/// Useful for debugging locally, setting this to true will cause all JSON
|
|
/// communication to be printed to stdout.
|
|
const debugPrintCommunication = false;
|
|
|
|
final beginningOfDocument = Range(
|
|
start: Position(line: 0, character: 0),
|
|
end: Position(line: 0, character: 0));
|
|
|
|
abstract class AbstractLspAnalysisServerTest
|
|
with
|
|
ResourceProviderMixin,
|
|
ClientCapabilitiesHelperMixin,
|
|
LspAnalysisServerTestMixin,
|
|
ConfigurationFilesMixin {
|
|
MockLspServerChannel channel;
|
|
TestPluginManager pluginManager;
|
|
LspAnalysisServer server;
|
|
MockHttpClient httpClient;
|
|
|
|
AnalysisServerOptions get serverOptions => AnalysisServerOptions();
|
|
|
|
@override
|
|
Stream<Message> get serverToClient => channel.serverToClient;
|
|
|
|
DiscoveredPluginInfo configureTestPlugin({
|
|
plugin.ResponseResult respondWith,
|
|
plugin.Notification notification,
|
|
Duration respondAfter = Duration.zero,
|
|
}) {
|
|
final info = DiscoveredPluginInfo('a', 'b', 'c', null, null);
|
|
pluginManager.plugins.add(info);
|
|
|
|
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;
|
|
}
|
|
|
|
/// 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 {
|
|
final resp = await sendRequestToServer(request);
|
|
if (resp.error != null) {
|
|
throw resp.error;
|
|
} else {
|
|
return resp.result == null ? null : fromJson(resp.result);
|
|
}
|
|
}
|
|
|
|
/// Finds the registration for a given LSP method.
|
|
Registration registrationFor(
|
|
List<Registration> registrations,
|
|
Method method,
|
|
) {
|
|
return registrations.singleWhere((r) => r.method == method.toJson(),
|
|
orElse: () => null);
|
|
}
|
|
|
|
@override
|
|
Future sendNotificationToServer(NotificationMessage notification) async {
|
|
channel.sendNotificationToServer(notification);
|
|
await pumpEventQueue();
|
|
}
|
|
|
|
@override
|
|
Future<ResponseMessage> sendRequestToServer(RequestMessage request) {
|
|
return channel.sendRequestToServer(request);
|
|
}
|
|
|
|
@override
|
|
void sendResponseToServer(ResponseMessage response) {
|
|
channel.sendResponseToServer(response);
|
|
}
|
|
|
|
void setUp() {
|
|
httpClient = MockHttpClient();
|
|
channel = MockLspServerChannel(debugPrintCommunication);
|
|
// Create an SDK in the mock file system.
|
|
MockSdk(resourceProvider: resourceProvider);
|
|
pluginManager = TestPluginManager();
|
|
server = LspAnalysisServer(
|
|
channel,
|
|
resourceProvider,
|
|
serverOptions,
|
|
DartSdkManager(convertPath('/sdk')),
|
|
CrashReportingAttachmentsBuilder.empty,
|
|
InstrumentationService.NULL_SERVICE,
|
|
httpClient: httpClient);
|
|
server.pluginManager = pluginManager;
|
|
|
|
projectFolderPath = convertPath('/home/test');
|
|
projectFolderUri = Uri.file(projectFolderPath);
|
|
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');
|
|
mainFileUri = Uri.file(mainFilePath);
|
|
pubspecFilePath = join(projectFolderPath, 'pubspec.yaml');
|
|
pubspecFileUri = Uri.file(pubspecFilePath);
|
|
analysisOptionsPath = join(projectFolderPath, 'analysis_options.yaml');
|
|
analysisOptionsUri = Uri.file(analysisOptionsPath);
|
|
writePackageConfig(projectFolderPath);
|
|
}
|
|
|
|
Future tearDown() async {
|
|
channel.close();
|
|
await server.shutdown();
|
|
}
|
|
}
|
|
|
|
mixin ClientCapabilitiesHelperMixin {
|
|
final emptyTextDocumentClientCapabilities = TextDocumentClientCapabilities();
|
|
|
|
final emptyWorkspaceClientCapabilities = ClientCapabilitiesWorkspace();
|
|
|
|
final emptyWindowClientCapabilities = ClientCapabilitiesWindow();
|
|
|
|
TextDocumentClientCapabilities extendTextDocumentCapabilities(
|
|
TextDocumentClientCapabilities source,
|
|
Map<String, dynamic> textDocumentCapabilities,
|
|
) {
|
|
final json = source.toJson();
|
|
mergeJson(textDocumentCapabilities, json);
|
|
return TextDocumentClientCapabilities.fromJson(json);
|
|
}
|
|
|
|
ClientCapabilitiesWindow extendWindowCapabilities(
|
|
ClientCapabilitiesWindow source,
|
|
Map<String, dynamic> windowCapabilities,
|
|
) {
|
|
final json = source.toJson();
|
|
mergeJson(windowCapabilities, json);
|
|
return ClientCapabilitiesWindow.fromJson(json);
|
|
}
|
|
|
|
ClientCapabilitiesWorkspace extendWorkspaceCapabilities(
|
|
ClientCapabilitiesWorkspace source,
|
|
Map<String, dynamic> workspaceCapabilities,
|
|
) {
|
|
final json = source.toJson();
|
|
mergeJson(workspaceCapabilities, json);
|
|
return ClientCapabilitiesWorkspace.fromJson(json);
|
|
}
|
|
|
|
void mergeJson(Map<String, dynamic> source, Map<String, dynamic> dest) {
|
|
source.keys.forEach((key) {
|
|
if (source[key] is Map<String, dynamic> &&
|
|
dest[key] is Map<String, dynamic>) {
|
|
mergeJson(source[key], dest[key]);
|
|
} else {
|
|
dest[key] = source[key];
|
|
}
|
|
});
|
|
}
|
|
|
|
TextDocumentClientCapabilities
|
|
withAllSupportedTextDocumentDynamicRegistrations(
|
|
TextDocumentClientCapabilities source,
|
|
) {
|
|
// This list (when combined with the workspace list) should match all of
|
|
// the fields listed in `ClientDynamicRegistrations.supported`.
|
|
return extendTextDocumentCapabilities(source, {
|
|
'synchronization': {'dynamicRegistration': true},
|
|
'completion': {'dynamicRegistration': true},
|
|
'hover': {'dynamicRegistration': true},
|
|
'signatureHelp': {'dynamicRegistration': true},
|
|
'references': {'dynamicRegistration': true},
|
|
'documentHighlight': {'dynamicRegistration': true},
|
|
'documentSymbol': {'dynamicRegistration': true},
|
|
'formatting': {'dynamicRegistration': true},
|
|
'onTypeFormatting': {'dynamicRegistration': true},
|
|
'rangeFormatting': {'dynamicRegistration': true},
|
|
'declaration': {'dynamicRegistration': true},
|
|
'definition': {'dynamicRegistration': true},
|
|
'implementation': {'dynamicRegistration': true},
|
|
'codeAction': {'dynamicRegistration': true},
|
|
'rename': {'dynamicRegistration': true},
|
|
'foldingRange': {'dynamicRegistration': true},
|
|
'semanticTokens': SemanticTokensClientCapabilities(
|
|
dynamicRegistration: true,
|
|
requests: SemanticTokensClientCapabilitiesRequests(),
|
|
formats: [],
|
|
tokenModifiers: [],
|
|
tokenTypes: []).toJson(),
|
|
});
|
|
}
|
|
|
|
ClientCapabilitiesWorkspace withAllSupportedWorkspaceDynamicRegistrations(
|
|
ClientCapabilitiesWorkspace source,
|
|
) {
|
|
// This list (when combined with the textDocument list) should match all of
|
|
// the fields listed in `ClientDynamicRegistrations.supported`.
|
|
return extendWorkspaceCapabilities(source, {
|
|
'fileOperations': {'dynamicRegistration': true},
|
|
});
|
|
}
|
|
|
|
ClientCapabilitiesWorkspace withApplyEditSupport(
|
|
ClientCapabilitiesWorkspace source,
|
|
) {
|
|
return extendWorkspaceCapabilities(source, {'applyEdit': true});
|
|
}
|
|
|
|
TextDocumentClientCapabilities withCodeActionKinds(
|
|
TextDocumentClientCapabilities source,
|
|
List<CodeActionKind> kinds,
|
|
) {
|
|
return extendTextDocumentCapabilities(source, {
|
|
'codeAction': {
|
|
'codeActionLiteralSupport': {
|
|
'codeActionKind': {'valueSet': kinds.map((k) => k.toJson()).toList()}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
TextDocumentClientCapabilities withCompletionItemDeprecatedFlagSupport(
|
|
TextDocumentClientCapabilities source,
|
|
) {
|
|
return extendTextDocumentCapabilities(source, {
|
|
'completion': {
|
|
'completionItem': {'deprecatedSupport': true}
|
|
}
|
|
});
|
|
}
|
|
|
|
TextDocumentClientCapabilities withCompletionItemInsertReplaceSupport(
|
|
TextDocumentClientCapabilities source,
|
|
) {
|
|
return extendTextDocumentCapabilities(source, {
|
|
'completion': {
|
|
'completionItem': {'insertReplaceSupport': true}
|
|
}
|
|
});
|
|
}
|
|
|
|
TextDocumentClientCapabilities withCompletionItemInsertTextModeSupport(
|
|
TextDocumentClientCapabilities source,
|
|
) {
|
|
return extendTextDocumentCapabilities(source, {
|
|
'completion': {
|
|
'completionItem': {
|
|
'insertTextModeSupport': {
|
|
'valueSet': [InsertTextMode.adjustIndentation, InsertTextMode.asIs]
|
|
.map((k) => k.toJson())
|
|
.toList()
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
TextDocumentClientCapabilities withCompletionItemKinds(
|
|
TextDocumentClientCapabilities source,
|
|
List<CompletionItemKind> kinds,
|
|
) {
|
|
return extendTextDocumentCapabilities(source, {
|
|
'completion': {
|
|
'completionItemKind': {
|
|
'valueSet': kinds.map((k) => k.toJson()).toList()
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
TextDocumentClientCapabilities withCompletionItemSnippetSupport(
|
|
TextDocumentClientCapabilities source,
|
|
) {
|
|
return extendTextDocumentCapabilities(source, {
|
|
'completion': {
|
|
'completionItem': {'snippetSupport': true}
|
|
}
|
|
});
|
|
}
|
|
|
|
TextDocumentClientCapabilities withCompletionItemTagSupport(
|
|
TextDocumentClientCapabilities source,
|
|
List<CompletionItemTag> tags,
|
|
) {
|
|
return extendTextDocumentCapabilities(source, {
|
|
'completion': {
|
|
'completionItem': {
|
|
'tagSupport': {'valueSet': tags.map((k) => k.toJson()).toList()}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
ClientCapabilitiesWorkspace withConfigurationSupport(
|
|
ClientCapabilitiesWorkspace source,
|
|
) {
|
|
return extendWorkspaceCapabilities(source, {'configuration': true});
|
|
}
|
|
|
|
TextDocumentClientCapabilities withDiagnosticTagSupport(
|
|
TextDocumentClientCapabilities source,
|
|
List<DiagnosticTag> tags,
|
|
) {
|
|
return extendTextDocumentCapabilities(source, {
|
|
'publishDiagnostics': {
|
|
'tagSupport': {'valueSet': tags.map((k) => k.toJson()).toList()}
|
|
}
|
|
});
|
|
}
|
|
|
|
ClientCapabilitiesWorkspace withDidChangeConfigurationDynamicRegistration(
|
|
ClientCapabilitiesWorkspace source,
|
|
) {
|
|
return extendWorkspaceCapabilities(source, {
|
|
'didChangeConfiguration': {'dynamicRegistration': true}
|
|
});
|
|
}
|
|
|
|
ClientCapabilitiesWorkspace withDocumentChangesSupport(
|
|
ClientCapabilitiesWorkspace source,
|
|
) {
|
|
return extendWorkspaceCapabilities(source, {
|
|
'workspaceEdit': {'documentChanges': true}
|
|
});
|
|
}
|
|
|
|
TextDocumentClientCapabilities withDocumentFormattingDynamicRegistration(
|
|
TextDocumentClientCapabilities source,
|
|
) {
|
|
return extendTextDocumentCapabilities(source, {
|
|
'formatting': {'dynamicRegistration': true},
|
|
'onTypeFormatting': {'dynamicRegistration': true},
|
|
'rangeFormatting': {'dynamicRegistration': true},
|
|
});
|
|
}
|
|
|
|
TextDocumentClientCapabilities withDocumentSymbolKinds(
|
|
TextDocumentClientCapabilities source,
|
|
List<SymbolKind> kinds,
|
|
) {
|
|
return extendTextDocumentCapabilities(source, {
|
|
'documentSymbol': {
|
|
'symbolKind': {'valueSet': kinds.map((k) => k.toJson()).toList()}
|
|
}
|
|
});
|
|
}
|
|
|
|
ClientCapabilitiesWorkspace withFileOperationDynamicRegistration(
|
|
ClientCapabilitiesWorkspace source,
|
|
) {
|
|
return extendWorkspaceCapabilities(source, {
|
|
'fileOperations': {'dynamicRegistration': true}
|
|
});
|
|
}
|
|
|
|
TextDocumentClientCapabilities withHierarchicalDocumentSymbolSupport(
|
|
TextDocumentClientCapabilities source,
|
|
) {
|
|
return extendTextDocumentCapabilities(source, {
|
|
'documentSymbol': {'hierarchicalDocumentSymbolSupport': true}
|
|
});
|
|
}
|
|
|
|
TextDocumentClientCapabilities withHoverContentFormat(
|
|
TextDocumentClientCapabilities source,
|
|
List<MarkupKind> formats,
|
|
) {
|
|
return extendTextDocumentCapabilities(source, {
|
|
'hover': {'contentFormat': formats.map((k) => k.toJson()).toList()}
|
|
});
|
|
}
|
|
|
|
TextDocumentClientCapabilities withHoverDynamicRegistration(
|
|
TextDocumentClientCapabilities source,
|
|
) {
|
|
return extendTextDocumentCapabilities(source, {
|
|
'hover': {'dynamicRegistration': true}
|
|
});
|
|
}
|
|
|
|
TextDocumentClientCapabilities withLocationLinkSupport(
|
|
TextDocumentClientCapabilities source,
|
|
) {
|
|
return extendTextDocumentCapabilities(source, {
|
|
'definition': {'linkSupport': true},
|
|
'implementation': {'linkSupport': true}
|
|
});
|
|
}
|
|
|
|
ClientCapabilitiesWorkspace withResourceOperationKinds(
|
|
ClientCapabilitiesWorkspace source,
|
|
List<ResourceOperationKind> kinds,
|
|
) {
|
|
return extendWorkspaceCapabilities(source, {
|
|
'workspaceEdit': {
|
|
'documentChanges':
|
|
true, // docChanges aren't included in resourceOperations
|
|
'resourceOperations': kinds.map((k) => k.toJson()).toList(),
|
|
}
|
|
});
|
|
}
|
|
|
|
TextDocumentClientCapabilities withSignatureHelpContentFormat(
|
|
TextDocumentClientCapabilities source,
|
|
List<MarkupKind> formats,
|
|
) {
|
|
return extendTextDocumentCapabilities(source, {
|
|
'signatureHelp': {
|
|
'signatureInformation': {
|
|
'documentationFormat': formats.map((k) => k.toJson()).toList()
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
TextDocumentClientCapabilities withTextSyncDynamicRegistration(
|
|
TextDocumentClientCapabilities source,
|
|
) {
|
|
return extendTextDocumentCapabilities(source, {
|
|
'synchronization': {'dynamicRegistration': true}
|
|
});
|
|
}
|
|
|
|
ClientCapabilitiesWindow withWorkDoneProgressSupport(
|
|
ClientCapabilitiesWindow source) {
|
|
return extendWindowCapabilities(source, {'workDoneProgress': true});
|
|
}
|
|
}
|
|
|
|
mixin ConfigurationFilesMixin on ResourceProviderMixin {
|
|
String get latestLanguageVersion =>
|
|
'${ExperimentStatus.currentVersion.major}.'
|
|
'${ExperimentStatus.currentVersion.minor}';
|
|
|
|
String get testPackageLanguageVersion => '2.9';
|
|
|
|
void writePackageConfig(
|
|
String projectFolderPath, {
|
|
PackageConfigFileBuilder config,
|
|
String languageVersion,
|
|
bool flutter = false,
|
|
bool meta = false,
|
|
bool pedantic = false,
|
|
bool vector_math = false,
|
|
}) {
|
|
if (config == null) {
|
|
config = PackageConfigFileBuilder();
|
|
} else {
|
|
config = config.copy();
|
|
}
|
|
|
|
config.add(
|
|
name: 'test',
|
|
rootPath: projectFolderPath,
|
|
languageVersion: languageVersion ?? testPackageLanguageVersion,
|
|
);
|
|
|
|
if (meta || flutter) {
|
|
var libFolder = MockPackages.instance.addMeta(resourceProvider);
|
|
config.add(name: 'meta', rootPath: libFolder.parent2.path);
|
|
}
|
|
|
|
if (flutter) {
|
|
{
|
|
var libFolder = MockPackages.instance.addUI(resourceProvider);
|
|
config.add(name: 'ui', rootPath: libFolder.parent2.path);
|
|
}
|
|
{
|
|
var libFolder = MockPackages.instance.addFlutter(resourceProvider);
|
|
config.add(name: 'flutter', rootPath: libFolder.parent2.path);
|
|
}
|
|
}
|
|
|
|
if (pedantic) {
|
|
var libFolder = MockPackages.instance.addPedantic(resourceProvider);
|
|
config.add(name: 'pedantic', rootPath: libFolder.parent2.path);
|
|
}
|
|
|
|
if (vector_math) {
|
|
var libFolder = MockPackages.instance.addVectorMath(resourceProvider);
|
|
config.add(name: 'vector_math', rootPath: libFolder.parent2.path);
|
|
}
|
|
|
|
var path = '$projectFolderPath/.dart_tool/package_config.json';
|
|
var content = config.toContent(toUriStr: toUriStr);
|
|
newFile(path, content: content);
|
|
}
|
|
}
|
|
|
|
mixin LspAnalysisServerTestMixin implements ClientCapabilitiesHelperMixin {
|
|
static const positionMarker = '^';
|
|
static const rangeMarkerStart = '[[';
|
|
static const rangeMarkerEnd = ']]';
|
|
static const allMarkers = [positionMarker, rangeMarkerStart, rangeMarkerEnd];
|
|
static final allMarkersPattern =
|
|
RegExp(allMarkers.map(RegExp.escape).join('|'));
|
|
|
|
/// 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 =
|
|
Either2<num, String>.t2('client-test');
|
|
|
|
int _id = 0;
|
|
String projectFolderPath, mainFilePath, pubspecFilePath, analysisOptionsPath;
|
|
Uri projectFolderUri, mainFileUri, pubspecFileUri, analysisOptionsUri;
|
|
final String simplePubspecContent = 'name: my_project';
|
|
final startOfDocPos = Position(line: 0, character: 0);
|
|
final startOfDocRange = Range(
|
|
start: Position(line: 0, character: 0),
|
|
end: Position(line: 0, character: 0));
|
|
|
|
/// The client capabilities sent to the server during initialization.
|
|
///
|
|
/// null if an initialization request has not yet been sent.
|
|
ClientCapabilities _clientCapabilities;
|
|
|
|
final validProgressTokens = <Either2<num, String>>{};
|
|
|
|
/// A stream of [NotificationMessage]s from the server that may be errors.
|
|
Stream<NotificationMessage> get errorNotificationsFromServer {
|
|
return notificationsFromServer.where(_isErrorNotification);
|
|
}
|
|
|
|
bool get initialized => _clientCapabilities != null;
|
|
|
|
/// A stream of [NotificationMessage]s from the server.
|
|
Stream<NotificationMessage> get notificationsFromServer {
|
|
return serverToClient
|
|
.where((m) => m is NotificationMessage)
|
|
.cast<NotificationMessage>();
|
|
}
|
|
|
|
/// A stream of [RequestMessage]s from the server.
|
|
Stream<RequestMessage> get requestsFromServer {
|
|
return serverToClient
|
|
.where((m) => m is RequestMessage)
|
|
.cast<RequestMessage>();
|
|
}
|
|
|
|
Stream<Message> get serverToClient;
|
|
|
|
void applyChanges(
|
|
Map<String, String> fileContents,
|
|
Map<String, List<TextEdit>> changes,
|
|
) {
|
|
changes.forEach((fileUri, edits) {
|
|
final path = Uri.parse(fileUri).toFilePath();
|
|
fileContents[path] = applyTextEdits(fileContents[path], edits);
|
|
});
|
|
}
|
|
|
|
void applyDocumentChanges(
|
|
Map<String, String> fileContents,
|
|
Either2<List<TextDocumentEdit>,
|
|
List<Either4<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>>>
|
|
documentChanges, {
|
|
Map<String, int> expectedVersions,
|
|
}) {
|
|
// If we were supplied with expected versions, ensure that all returned
|
|
// edits match the versions.
|
|
if (expectedVersions != null) {
|
|
expectDocumentVersions(documentChanges, expectedVersions);
|
|
}
|
|
documentChanges.map(
|
|
(edits) => applyTextDocumentEdits(fileContents, edits),
|
|
(changes) => applyResourceChanges(fileContents, changes),
|
|
);
|
|
}
|
|
|
|
void applyResourceChanges(
|
|
Map<String, String> oldFileContent,
|
|
List<Either4<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>> changes,
|
|
) {
|
|
for (final change in changes) {
|
|
change.map(
|
|
(textDocEdit) => applyTextDocumentEdits(oldFileContent, [textDocEdit]),
|
|
(create) => applyResourceCreate(oldFileContent, create),
|
|
(rename) => throw 'applyResourceChanges:Delete not currently supported',
|
|
(delete) => throw 'applyResourceChanges:Delete not currently supported',
|
|
);
|
|
}
|
|
}
|
|
|
|
void applyResourceCreate(
|
|
Map<String, String> oldFileContent, CreateFile create) {
|
|
final path = Uri.parse(create.uri).toFilePath();
|
|
if (oldFileContent.containsKey(path)) {
|
|
throw 'Recieved create instruction for $path which already existed.';
|
|
}
|
|
oldFileContent[path] = '';
|
|
}
|
|
|
|
String applyTextDocumentEdit(String content, TextDocumentEdit edit) {
|
|
return edit.edits.fold(content, applyTextEdit);
|
|
}
|
|
|
|
void applyTextDocumentEdits(
|
|
Map<String, String> oldFileContent, List<TextDocumentEdit> edits) {
|
|
edits.forEach((edit) {
|
|
final path = Uri.parse(edit.textDocument.uri).toFilePath();
|
|
if (!oldFileContent.containsKey(path)) {
|
|
throw 'Recieved edits for $path which was not provided as a file to be edited. '
|
|
'Perhaps a CreateFile change was missing from the edits?';
|
|
}
|
|
oldFileContent[path] = applyTextDocumentEdit(oldFileContent[path], edit);
|
|
});
|
|
}
|
|
|
|
String applyTextEdit(String content,
|
|
Either3<SnippetTextEdit, AnnotatedTextEdit, TextEdit> change) {
|
|
// Both sites of the union can cast to TextEdit.
|
|
final edit = change.map((e) => e, (e) => e, (e) => e);
|
|
final startPos = edit.range.start;
|
|
final endPos = edit.range.end;
|
|
final lineInfo = LineInfo.fromContent(content);
|
|
final start = lineInfo.getOffsetOfLine(startPos.line) + startPos.character;
|
|
final end = lineInfo.getOffsetOfLine(endPos.line) + endPos.character;
|
|
return content.replaceRange(start, end, edit.newText);
|
|
}
|
|
|
|
String applyTextEdits(String oldContent, List<TextEdit> changes) {
|
|
var newContent = oldContent;
|
|
// Complex text manipulations are described with an array of TextEdit's,
|
|
// representing a single change to the document.
|
|
//
|
|
// All text edits ranges refer to positions in the original document. Text
|
|
// edits ranges must never overlap, that means no part of the original
|
|
// document must be manipulated by more than one edit. It is possible
|
|
// that multiple edits have the same start position (eg. multiple inserts in
|
|
// reverse order), however since that involves complicated tracking and we
|
|
// only apply edits here sequentially, we don't supported them. We do sort
|
|
// edits to ensure we apply the later ones first, so we can assume the locations
|
|
// in the edit are still valid against the new string as each edit is applied.
|
|
|
|
/// Ensures changes are simple enough to apply easily without any complicated
|
|
/// logic.
|
|
void validateChangesCanBeApplied() {
|
|
/// Check if a position is before (but not equal) to another position.
|
|
bool isBeforeOrEqual(Position p, Position other) =>
|
|
p.line < other.line ||
|
|
(p.line == other.line && p.character <= other.character);
|
|
|
|
/// Check if a position is after (but not equal) to another position.
|
|
bool isAfterOrEqual(Position p, Position other) =>
|
|
p.line > other.line ||
|
|
(p.line == other.line && p.character >= other.character);
|
|
// Check if two ranges intersect.
|
|
bool rangesIntersect(Range r1, Range r2) {
|
|
var endsBefore = isBeforeOrEqual(r1.end, r2.start);
|
|
var startsAfter = isAfterOrEqual(r1.start, r2.end);
|
|
return !(endsBefore || startsAfter);
|
|
}
|
|
|
|
for (final change1 in changes) {
|
|
for (final change2 in changes) {
|
|
if (change1 != change2 &&
|
|
rangesIntersect(change1.range, change2.range)) {
|
|
throw 'Test helper applyTextEdits does not support applying multiple edits '
|
|
'where the edits are not in reverse order.';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
validateChangesCanBeApplied();
|
|
final sortedChanges = changes.toList() // Don't mutate the original list.
|
|
..sort(
|
|
// Multiply by -1 to get descending sort.
|
|
(c1, c2) => positionCompare(c1.range.start, c2.range.start) * -1,
|
|
);
|
|
|
|
for (final change in sortedChanges) {
|
|
newContent = applyTextEdit(newContent,
|
|
Either3<SnippetTextEdit, AnnotatedTextEdit, TextEdit>.t3(change));
|
|
}
|
|
|
|
return newContent;
|
|
}
|
|
|
|
Future changeFile(
|
|
int newVersion,
|
|
Uri uri,
|
|
List<
|
|
Either2<TextDocumentContentChangeEvent1,
|
|
TextDocumentContentChangeEvent2>>
|
|
changes,
|
|
) async {
|
|
var notification = makeNotification(
|
|
Method.textDocument_didChange,
|
|
DidChangeTextDocumentParams(
|
|
textDocument: VersionedTextDocumentIdentifier(
|
|
version: newVersion, uri: uri.toString()),
|
|
contentChanges: changes,
|
|
),
|
|
);
|
|
await sendNotificationToServer(notification);
|
|
}
|
|
|
|
Future 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 closeFile(Uri uri) async {
|
|
var notification = makeNotification(
|
|
Method.textDocument_didClose,
|
|
DidCloseTextDocumentParams(
|
|
textDocument: TextDocumentIdentifier(uri: uri.toString())),
|
|
);
|
|
await sendNotificationToServer(notification);
|
|
}
|
|
|
|
Future<Object> executeCodeAction(
|
|
Either2<Command, CodeAction> codeAction) async {
|
|
final command = codeAction.map(
|
|
(command) => command,
|
|
(codeAction) => codeAction.command,
|
|
);
|
|
return executeCommand(command);
|
|
}
|
|
|
|
Future<Object> executeCommand(Command command,
|
|
{Either2<num, String> workDoneToken}) async {
|
|
final request = makeRequest(
|
|
Method.workspace_executeCommand,
|
|
ExecuteCommandParams(
|
|
command: command.command,
|
|
arguments: command.arguments,
|
|
workDoneToken: workDoneToken,
|
|
),
|
|
);
|
|
return expectSuccessfulResponseTo(request, (result) => result);
|
|
}
|
|
|
|
void expect(actual, matcher, {String reason}) =>
|
|
test.expect(actual, matcher, reason: reason);
|
|
|
|
void expectDocumentVersion(
|
|
TextDocumentEdit edit,
|
|
Map<String, int> expectedVersions,
|
|
) {
|
|
final path = Uri.parse(edit.textDocument.uri).toFilePath();
|
|
final expectedVersion = expectedVersions[path];
|
|
|
|
if (edit.textDocument is OptionalVersionedTextDocumentIdentifier) {
|
|
expect(edit.textDocument.version, equals(expectedVersion));
|
|
} else {
|
|
throw 'Document identifier for $path was not versioned (expected version $expectedVersion)';
|
|
}
|
|
}
|
|
|
|
/// Validates the document versions for a set of edits match the versions in
|
|
/// the supplied map.
|
|
void expectDocumentVersions(
|
|
Either2<List<TextDocumentEdit>,
|
|
List<Either4<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>>>
|
|
documentChanges,
|
|
Map<String, int> expectedVersions,
|
|
) {
|
|
documentChanges.map(
|
|
// Validate versions on simple doc edits
|
|
(edits) => edits
|
|
.forEach((edit) => expectDocumentVersion(edit, expectedVersions)),
|
|
// For resource changes, we only need to validate changes since
|
|
// creates/renames/deletes do not supply versions.
|
|
(changes) => changes.forEach((change) {
|
|
change.map(
|
|
(edit) => expectDocumentVersion(edit, expectedVersions),
|
|
(create) => {},
|
|
(rename) {},
|
|
(delete) {},
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
|
|
Future<ShowMessageParams> expectErrorNotification(
|
|
FutureOr<void> Function() f, {
|
|
Duration timeout = const Duration(seconds: 5),
|
|
}) async {
|
|
final firstError = errorNotificationsFromServer.first;
|
|
await f();
|
|
|
|
final notificationFromServer = await firstError.timeout(timeout);
|
|
|
|
expect(notificationFromServer, isNotNull);
|
|
return ShowMessageParams.fromJson(notificationFromServer.params);
|
|
}
|
|
|
|
Future<T> expectNotification<T>(
|
|
bool Function(NotificationMessage) test,
|
|
FutureOr<void> Function() f, {
|
|
Duration timeout = const Duration(seconds: 5),
|
|
}) async {
|
|
final firstError = notificationsFromServer.firstWhere(test);
|
|
await f();
|
|
|
|
final notificationFromServer = await firstError.timeout(timeout);
|
|
|
|
expect(notificationFromServer, isNotNull);
|
|
return notificationFromServer.params as T;
|
|
}
|
|
|
|
/// Expects a [method] request from the server after executing [f].
|
|
Future<RequestMessage> expectRequest(
|
|
Method method,
|
|
FutureOr<void> Function() f, {
|
|
Duration timeout = const Duration(seconds: 5),
|
|
}) async {
|
|
final firstRequest =
|
|
requestsFromServer.firstWhere((n) => n.method == method);
|
|
await f();
|
|
|
|
final requestFromServer = await firstRequest.timeout(timeout);
|
|
|
|
expect(requestFromServer, isNotNull);
|
|
return requestFromServer;
|
|
}
|
|
|
|
Future<T> expectSuccessfulResponseTo<T, R>(
|
|
RequestMessage request, T Function(R) fromJson);
|
|
|
|
Future<List<TextEdit>> formatDocument(String fileUri) {
|
|
final request = makeRequest(
|
|
Method.textDocument_formatting,
|
|
DocumentFormattingParams(
|
|
textDocument: TextDocumentIdentifier(uri: fileUri),
|
|
options: FormattingOptions(
|
|
tabSize: 2,
|
|
insertSpaces: true), // These currently don't do anything
|
|
),
|
|
);
|
|
return expectSuccessfulResponseTo(
|
|
request, _fromJsonList(TextEdit.fromJson));
|
|
}
|
|
|
|
Future<List<TextEdit>> formatOnType(
|
|
String fileUri, Position pos, String character) {
|
|
final request = makeRequest(
|
|
Method.textDocument_onTypeFormatting,
|
|
DocumentOnTypeFormattingParams(
|
|
ch: character,
|
|
options: FormattingOptions(
|
|
tabSize: 2,
|
|
insertSpaces: true), // These currently don't do anything
|
|
textDocument: TextDocumentIdentifier(uri: fileUri),
|
|
position: pos,
|
|
),
|
|
);
|
|
return expectSuccessfulResponseTo(
|
|
request, _fromJsonList(TextEdit.fromJson));
|
|
}
|
|
|
|
Future<List<TextEdit>> formatRange(String fileUri, Range range) {
|
|
final request = makeRequest(
|
|
Method.textDocument_rangeFormatting,
|
|
DocumentRangeFormattingParams(
|
|
options: FormattingOptions(
|
|
tabSize: 2,
|
|
insertSpaces: true), // These currently don't do anything
|
|
textDocument: TextDocumentIdentifier(uri: fileUri),
|
|
range: range,
|
|
),
|
|
);
|
|
return expectSuccessfulResponseTo(
|
|
request, _fromJsonList(TextEdit.fromJson));
|
|
}
|
|
|
|
Future<List<Either2<Command, CodeAction>>> getCodeActions(
|
|
String fileUri, {
|
|
Range range,
|
|
List<CodeActionKind> kinds,
|
|
}) {
|
|
final request = makeRequest(
|
|
Method.textDocument_codeAction,
|
|
CodeActionParams(
|
|
textDocument: TextDocumentIdentifier(uri: fileUri),
|
|
range: range ?? beginningOfDocument,
|
|
// TODO(dantup): We may need to revise the tests/implementation when
|
|
// it's clear how we're supposed to handle diagnostics:
|
|
// https://github.com/Microsoft/language-server-protocol/issues/583
|
|
context: CodeActionContext(diagnostics: [], only: kinds),
|
|
),
|
|
);
|
|
return expectSuccessfulResponseTo(
|
|
request,
|
|
_fromJsonList(_generateFromJsonFor(Command.canParse, Command.fromJson,
|
|
CodeAction.canParse, CodeAction.fromJson)),
|
|
);
|
|
}
|
|
|
|
Future<List<CompletionItem>> getCompletion(Uri uri, Position pos,
|
|
{CompletionContext context}) {
|
|
final request = makeRequest(
|
|
Method.textDocument_completion,
|
|
CompletionParams(
|
|
context: context,
|
|
textDocument: TextDocumentIdentifier(uri: uri.toString()),
|
|
position: pos,
|
|
),
|
|
);
|
|
return expectSuccessfulResponseTo(
|
|
request, _fromJsonList(CompletionItem.fromJson));
|
|
}
|
|
|
|
Future<Either2<List<Location>, List<LocationLink>>> getDefinition(
|
|
Uri uri, Position pos) {
|
|
final request = makeRequest(
|
|
Method.textDocument_definition,
|
|
TextDocumentPositionParams(
|
|
textDocument: TextDocumentIdentifier(uri: uri.toString()),
|
|
position: pos,
|
|
),
|
|
);
|
|
return expectSuccessfulResponseTo(
|
|
request,
|
|
_generateFromJsonFor(
|
|
_canParseList(Location.canParse),
|
|
_fromJsonList(Location.fromJson),
|
|
_canParseList(LocationLink.canParse),
|
|
_fromJsonList(LocationLink.fromJson)),
|
|
);
|
|
}
|
|
|
|
Future<List<Location>> getDefinitionAsLocation(Uri uri, Position pos) async {
|
|
final results = await getDefinition(uri, pos);
|
|
return results.map(
|
|
(locations) => locations,
|
|
(locationLinks) => throw 'Expected List<Location> got List<LocationLink>',
|
|
);
|
|
}
|
|
|
|
Future<List<LocationLink>> getDefinitionAsLocationLinks(
|
|
Uri uri, Position pos) async {
|
|
final results = await getDefinition(uri, pos);
|
|
return results.map(
|
|
(locations) => throw 'Expected List<LocationLink> got List<Location>',
|
|
(locationLinks) => locationLinks,
|
|
);
|
|
}
|
|
|
|
Future<DartDiagnosticServer> getDiagnosticServer() {
|
|
final request = makeRequest(
|
|
CustomMethods.diagnosticServer,
|
|
null,
|
|
);
|
|
return expectSuccessfulResponseTo(request, DartDiagnosticServer.fromJson);
|
|
}
|
|
|
|
Future<List<DocumentHighlight>> getDocumentHighlights(Uri uri, Position pos) {
|
|
final request = makeRequest(
|
|
Method.textDocument_documentHighlight,
|
|
TextDocumentPositionParams(
|
|
textDocument: TextDocumentIdentifier(uri: uri.toString()),
|
|
position: pos,
|
|
),
|
|
);
|
|
return expectSuccessfulResponseTo(
|
|
request, _fromJsonList(DocumentHighlight.fromJson));
|
|
}
|
|
|
|
Future<Either2<List<DocumentSymbol>, List<SymbolInformation>>>
|
|
getDocumentSymbols(String fileUri) {
|
|
final request = makeRequest(
|
|
Method.textDocument_documentSymbol,
|
|
DocumentSymbolParams(
|
|
textDocument: TextDocumentIdentifier(uri: fileUri),
|
|
),
|
|
);
|
|
return expectSuccessfulResponseTo(
|
|
request,
|
|
_generateFromJsonFor(
|
|
_canParseList(DocumentSymbol.canParse),
|
|
_fromJsonList(DocumentSymbol.fromJson),
|
|
_canParseList(SymbolInformation.canParse),
|
|
_fromJsonList(SymbolInformation.fromJson)),
|
|
);
|
|
}
|
|
|
|
Future<List<FoldingRange>> getFoldingRegions(Uri uri) {
|
|
final request = makeRequest(
|
|
Method.textDocument_foldingRange,
|
|
FoldingRangeParams(
|
|
textDocument: TextDocumentIdentifier(uri: uri.toString()),
|
|
),
|
|
);
|
|
return expectSuccessfulResponseTo(
|
|
request, _fromJsonList(FoldingRange.fromJson));
|
|
}
|
|
|
|
Future<Hover> getHover(Uri uri, Position pos) {
|
|
final request = makeRequest(
|
|
Method.textDocument_hover,
|
|
TextDocumentPositionParams(
|
|
textDocument: TextDocumentIdentifier(uri: uri.toString()),
|
|
position: pos),
|
|
);
|
|
return expectSuccessfulResponseTo(request, Hover.fromJson);
|
|
}
|
|
|
|
Future<List<Location>> getImplementations(
|
|
Uri uri,
|
|
Position pos, {
|
|
includeDeclarations = false,
|
|
}) {
|
|
final request = makeRequest(
|
|
Method.textDocument_implementation,
|
|
TextDocumentPositionParams(
|
|
textDocument: TextDocumentIdentifier(uri: uri.toString()),
|
|
position: pos,
|
|
),
|
|
);
|
|
return expectSuccessfulResponseTo(
|
|
request, _fromJsonList(Location.fromJson));
|
|
}
|
|
|
|
Future<List<Location>> getReferences(
|
|
Uri uri,
|
|
Position pos, {
|
|
includeDeclarations = false,
|
|
}) {
|
|
final request = makeRequest(
|
|
Method.textDocument_references,
|
|
ReferenceParams(
|
|
context: ReferenceContext(includeDeclaration: includeDeclarations),
|
|
textDocument: TextDocumentIdentifier(uri: uri.toString()),
|
|
position: pos,
|
|
),
|
|
);
|
|
return expectSuccessfulResponseTo(
|
|
request, _fromJsonList(Location.fromJson));
|
|
}
|
|
|
|
Future<CompletionItem> getResolvedCompletion(
|
|
Uri uri,
|
|
Position pos,
|
|
String label, {
|
|
CompletionContext context,
|
|
}) async {
|
|
final completions = await getCompletion(uri, pos, context: context);
|
|
|
|
final completion = completions.singleWhere((c) => c.label == label);
|
|
expect(completion, isNotNull);
|
|
|
|
return resolveCompletion(completion);
|
|
}
|
|
|
|
Future<SemanticTokens> getSemanticTokens(Uri uri) {
|
|
final request = makeRequest(
|
|
Method.textDocument_semanticTokens_full,
|
|
SemanticTokensParams(
|
|
textDocument: TextDocumentIdentifier(uri: uri.toString()),
|
|
),
|
|
);
|
|
return expectSuccessfulResponseTo(request, SemanticTokens.fromJson);
|
|
}
|
|
|
|
Future<SemanticTokens> getSemanticTokensRange(Uri uri, Range range) {
|
|
final request = makeRequest(
|
|
Method.textDocument_semanticTokens_range,
|
|
SemanticTokensRangeParams(
|
|
textDocument: TextDocumentIdentifier(uri: uri.toString()),
|
|
range: range,
|
|
),
|
|
);
|
|
return expectSuccessfulResponseTo(request, SemanticTokens.fromJson);
|
|
}
|
|
|
|
Future<SignatureHelp> getSignatureHelp(Uri uri, Position pos,
|
|
[SignatureHelpContext context]) {
|
|
final request = makeRequest(
|
|
Method.textDocument_signatureHelp,
|
|
SignatureHelpParams(
|
|
textDocument: TextDocumentIdentifier(uri: uri.toString()),
|
|
position: pos,
|
|
context: context,
|
|
),
|
|
);
|
|
return expectSuccessfulResponseTo(request, SignatureHelp.fromJson);
|
|
}
|
|
|
|
Future<Location> getSuper(
|
|
Uri uri,
|
|
Position pos,
|
|
) {
|
|
final request = makeRequest(
|
|
CustomMethods.super_,
|
|
TextDocumentPositionParams(
|
|
textDocument: TextDocumentIdentifier(uri: uri.toString()),
|
|
position: pos,
|
|
),
|
|
);
|
|
return expectSuccessfulResponseTo(request, Location.fromJson);
|
|
}
|
|
|
|
Future<List<SymbolInformation>> getWorkspaceSymbols(String query) {
|
|
final request = makeRequest(
|
|
Method.workspace_symbol,
|
|
WorkspaceSymbolParams(query: query),
|
|
);
|
|
return expectSuccessfulResponseTo(
|
|
request, _fromJsonList(SymbolInformation.fromJson));
|
|
}
|
|
|
|
/// Executes [f] then waits for a request of type [method] from the server which
|
|
/// is passed to [handler] to process, then waits for (and returns) the
|
|
/// response to the original request.
|
|
///
|
|
/// This is used for testing things like code actions, where the client initiates
|
|
/// a request but the server does not respond to it until it's sent its own
|
|
/// request to the client and it recieved a response.
|
|
///
|
|
/// Client Server
|
|
/// 1. |- Req: textDocument/codeAction ->
|
|
/// 1. <- Resp: textDocument/codeAction -|
|
|
///
|
|
/// 2. |- Req: workspace/executeCommand ->
|
|
/// 3. <- Req: textDocument/applyEdits -|
|
|
/// 3. |- Resp: textDocument/applyEdits ->
|
|
/// 2. <- Resp: workspace/executeCommand -|
|
|
///
|
|
/// Request 2 from the client is not responded to until the server has its own
|
|
/// response to the request it sends (3).
|
|
Future<T> handleExpectedRequest<T, R, RR>(
|
|
Method method,
|
|
R Function(Map<String, dynamic>) fromJson,
|
|
Future<T> Function() f, {
|
|
@required FutureOr<RR> Function(R) handler,
|
|
Duration timeout = const Duration(seconds: 5),
|
|
}) async {
|
|
Future<T> outboundRequest;
|
|
|
|
// Run [f] and wait for the incoming request from the server.
|
|
final incomingRequest = await expectRequest(method, () {
|
|
// Don't return/await the response yet, as this may not complete until
|
|
// after we have handled the request that comes from the server.
|
|
outboundRequest = f();
|
|
|
|
// Because we don't await this future until "later", if it throws the
|
|
// error is treated as unhandled and will fail the test. Attaching an
|
|
// error handler prevents that, though since the Future completed with
|
|
// an error it will still be handled as such when the future is later
|
|
// awaited.
|
|
outboundRequest.catchError((_) {});
|
|
});
|
|
|
|
// Handle the request from the server and send the response back.
|
|
final clientsResponse = await handler(fromJson(incomingRequest.params));
|
|
respondTo(incomingRequest, clientsResponse);
|
|
|
|
// Return a future that completes when the response to the original request
|
|
// (from [f]) returns.
|
|
return outboundRequest;
|
|
}
|
|
|
|
/// 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,
|
|
TextDocumentClientCapabilities textDocumentCapabilities,
|
|
ClientCapabilitiesWorkspace workspaceCapabilities,
|
|
ClientCapabilitiesWindow windowCapabilities,
|
|
Map<String, Object> experimentalCapabilities,
|
|
Map<String, Object> initializationOptions,
|
|
bool throwOnFailure = true,
|
|
bool allowEmptyRootUri = false,
|
|
}) async {
|
|
_clientCapabilities = ClientCapabilities(
|
|
workspace: workspaceCapabilities,
|
|
textDocument: textDocumentCapabilities,
|
|
window: windowCapabilities,
|
|
experimental: experimentalCapabilities,
|
|
);
|
|
|
|
// 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);
|
|
}
|
|
});
|
|
|
|
// 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 = Uri.file(projectFolderPath);
|
|
}
|
|
final request = makeRequest(
|
|
Method.initialize,
|
|
InitializeParams(
|
|
rootPath: rootPath,
|
|
rootUri: rootUri?.toString(),
|
|
initializationOptions: initializationOptions,
|
|
capabilities: _clientCapabilities,
|
|
workspaceFolders: workspaceFolders?.map(toWorkspaceFolder)?.toList(),
|
|
));
|
|
final response = await sendRequestToServer(request);
|
|
expect(response.id, equals(request.id));
|
|
|
|
if (response.error == null) {
|
|
final notification =
|
|
makeNotification(Method.initialized, InitializedParams());
|
|
await sendNotificationToServer(notification);
|
|
await pumpEventQueue();
|
|
} else if (throwOnFailure) {
|
|
throw 'Error during initialize request: '
|
|
'${response.error.code}: ${response.error.message}';
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
NotificationMessage makeNotification(Method method, ToJsonable params) {
|
|
return NotificationMessage(
|
|
method: method, params: params, jsonrpc: jsonRpcVersion);
|
|
}
|
|
|
|
RequestMessage makeRenameRequest(
|
|
int version, Uri uri, Position pos, String newName) {
|
|
final docIdentifier = version != null
|
|
? VersionedTextDocumentIdentifier(version: version, uri: uri.toString())
|
|
: TextDocumentIdentifier(uri: uri.toString());
|
|
final request = makeRequest(
|
|
Method.textDocument_rename,
|
|
RenameParams(
|
|
newName: newName, textDocument: docIdentifier, position: pos),
|
|
);
|
|
return request;
|
|
}
|
|
|
|
RequestMessage makeRequest(Method method, ToJsonable params) {
|
|
final id = Either2<num, String>.t1(_id++);
|
|
return RequestMessage(
|
|
id: id, method: method, params: params, jsonrpc: jsonRpcVersion);
|
|
}
|
|
|
|
/// Watches for `client/registerCapability` requests and updates
|
|
/// `registrations`.
|
|
Future<ResponseMessage> monitorDynamicRegistrations(
|
|
List<Registration> registrations,
|
|
Future<ResponseMessage> Function() f,
|
|
) {
|
|
return handleExpectedRequest<ResponseMessage, RegistrationParams, void>(
|
|
Method.client_registerCapability,
|
|
RegistrationParams.fromJson,
|
|
f,
|
|
handler: (registrationParams) {
|
|
registrations.addAll(registrationParams.registrations);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Expects both unregistration and reregistration.
|
|
Future<ResponseMessage> monitorDynamicReregistration(
|
|
List<Registration> registrations,
|
|
Future<ResponseMessage> Function() f,
|
|
) =>
|
|
monitorDynamicUnregistrations(
|
|
registrations,
|
|
() => monitorDynamicRegistrations(registrations, f),
|
|
);
|
|
|
|
/// Watches for `client/unregisterCapability` requests and updates
|
|
/// `registrations`.
|
|
Future<ResponseMessage> monitorDynamicUnregistrations(
|
|
List<Registration> registrations,
|
|
Future<ResponseMessage> Function() f,
|
|
) {
|
|
return handleExpectedRequest<ResponseMessage, UnregistrationParams, void>(
|
|
Method.client_unregisterCapability,
|
|
UnregistrationParams.fromJson,
|
|
f,
|
|
handler: (unregistrationParams) {
|
|
registrations.removeWhere((element) => unregistrationParams
|
|
.unregisterations
|
|
.any((u) => u.id == element.id));
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<WorkspaceEdit> onWillRename(List<FileRename> renames) {
|
|
final request = makeRequest(
|
|
Method.workspace_willRenameFiles,
|
|
RenameFilesParams(files: renames),
|
|
);
|
|
return expectSuccessfulResponseTo(request, WorkspaceEdit.fromJson);
|
|
}
|
|
|
|
Future openFile(Uri uri, String content, {num version = 1}) async {
|
|
var notification = makeNotification(
|
|
Method.textDocument_didOpen,
|
|
DidOpenTextDocumentParams(
|
|
textDocument: TextDocumentItem(
|
|
uri: uri.toString(),
|
|
languageId: dartLanguageId,
|
|
version: version,
|
|
text: content)),
|
|
);
|
|
await sendNotificationToServer(notification);
|
|
await pumpEventQueue();
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
Position positionFromMarker(String contents) =>
|
|
positionFromOffset(withoutRangeMarkers(contents).indexOf('^'), contents);
|
|
|
|
Position positionFromOffset(int offset, String contents) {
|
|
final lineInfo = LineInfo.fromContent(withoutMarkers(contents));
|
|
return toPosition(lineInfo.getLocation(offset));
|
|
}
|
|
|
|
Future<RangeAndPlaceholder> prepareRename(Uri uri, Position pos) {
|
|
final request = makeRequest(
|
|
Method.textDocument_prepareRename,
|
|
TextDocumentPositionParams(
|
|
textDocument: TextDocumentIdentifier(uri: uri.toString()),
|
|
position: pos,
|
|
),
|
|
);
|
|
return expectSuccessfulResponseTo(request, RangeAndPlaceholder.fromJson);
|
|
}
|
|
|
|
/// Calls the supplied function and responds to any `workspace/configuration`
|
|
/// request with the supplied config.
|
|
Future<ResponseMessage> provideConfig(Future<ResponseMessage> Function() f,
|
|
FutureOr<Map<String, dynamic>> config) {
|
|
return handleExpectedRequest<ResponseMessage, ConfigurationParams,
|
|
List<Map<String, dynamic>>>(
|
|
Method.workspace_configuration,
|
|
ConfigurationParams.fromJson,
|
|
f,
|
|
handler: (configurationParams) async => [await config],
|
|
);
|
|
}
|
|
|
|
/// Returns the range surrounded by `[[markers]]` in the provided string,
|
|
/// excluding the markers themselves (as well as position markers `^` from
|
|
/// the offsets).
|
|
Range rangeFromMarkers(String contents) {
|
|
final ranges = rangesFromMarkers(contents);
|
|
if (ranges.length == 1) {
|
|
return ranges.first;
|
|
} else if (ranges.isEmpty) {
|
|
throw 'Contents did not include a marked range';
|
|
} else {
|
|
throw 'Contents contained multiple ranges but only one was expected';
|
|
}
|
|
}
|
|
|
|
/// Returns the range of [searchText] in [content].
|
|
Range rangeOfString(String content, String searchText) {
|
|
content = withoutMarkers(content);
|
|
final startOffset = content.indexOf(searchText);
|
|
return startOffset == -1
|
|
? null
|
|
: Range(
|
|
start: positionFromOffset(startOffset, content),
|
|
end: positionFromOffset(startOffset + searchText.length, content),
|
|
);
|
|
}
|
|
|
|
/// Returns all ranges surrounded by `[[markers]]` in the provided string,
|
|
/// excluding the markers themselves (as well as position markers `^` from
|
|
/// the offsets).
|
|
List<Range> rangesFromMarkers(String content) {
|
|
Iterable<Range> rangesFromMarkersImpl(String content) sync* {
|
|
content = content.replaceAll(positionMarker, '');
|
|
final contentsWithoutMarkers = withoutMarkers(content);
|
|
var searchStartIndex = 0;
|
|
var offsetForEarlierMarkers = 0;
|
|
while (true) {
|
|
final startMarker = content.indexOf(rangeMarkerStart, searchStartIndex);
|
|
if (startMarker == -1) {
|
|
return; // Exit if we didn't find any more.
|
|
}
|
|
final endMarker = content.indexOf(rangeMarkerEnd, startMarker);
|
|
if (endMarker == -1) {
|
|
throw 'Found unclosed range starting at offset $startMarker';
|
|
}
|
|
yield Range(
|
|
start: positionFromOffset(
|
|
startMarker + offsetForEarlierMarkers, contentsWithoutMarkers),
|
|
end: positionFromOffset(
|
|
endMarker + offsetForEarlierMarkers - rangeMarkerStart.length,
|
|
contentsWithoutMarkers),
|
|
);
|
|
// Start the next search after this one, but remember to offset the future
|
|
// results by the lengths of these markers since they shouldn't affect the
|
|
// offsets.
|
|
searchStartIndex = endMarker;
|
|
offsetForEarlierMarkers -=
|
|
rangeMarkerStart.length + rangeMarkerEnd.length;
|
|
}
|
|
}
|
|
|
|
return rangesFromMarkersImpl(content).toList();
|
|
}
|
|
|
|
Future<WorkspaceEdit> rename(
|
|
Uri uri,
|
|
int version,
|
|
Position pos,
|
|
String newName,
|
|
) {
|
|
final request = makeRenameRequest(version, uri, pos, newName);
|
|
return expectSuccessfulResponseTo(request, WorkspaceEdit.fromJson);
|
|
}
|
|
|
|
Future<ResponseMessage> renameRaw(
|
|
Uri uri,
|
|
int version,
|
|
Position pos,
|
|
String newName,
|
|
) {
|
|
final request = makeRenameRequest(version, uri, pos, newName);
|
|
return sendRequestToServer(request);
|
|
}
|
|
|
|
Future replaceFile(int newVersion, Uri uri, String content) {
|
|
return changeFile(
|
|
newVersion,
|
|
uri,
|
|
[
|
|
Either2<TextDocumentContentChangeEvent1,
|
|
TextDocumentContentChangeEvent2>.t2(
|
|
TextDocumentContentChangeEvent2(text: content))
|
|
],
|
|
);
|
|
}
|
|
|
|
Future<CompletionItem> resolveCompletion(CompletionItem item) {
|
|
final request = makeRequest(
|
|
Method.completionItem_resolve,
|
|
item,
|
|
);
|
|
return expectSuccessfulResponseTo(request, CompletionItem.fromJson);
|
|
}
|
|
|
|
/// Sends [responseParams] to the server as a successful response to
|
|
/// a server-initiated [request].
|
|
void respondTo<T>(RequestMessage request, T responseParams) {
|
|
sendResponseToServer(ResponseMessage(
|
|
id: request.id, result: responseParams, jsonrpc: jsonRpcVersion));
|
|
}
|
|
|
|
Future<ResponseMessage> sendDidChangeConfiguration() {
|
|
final request = makeRequest(
|
|
Method.workspace_didChangeConfiguration,
|
|
DidChangeConfigurationParams(),
|
|
);
|
|
return sendRequestToServer(request);
|
|
}
|
|
|
|
void sendExit() {
|
|
final request = makeRequest(Method.exit, null);
|
|
sendRequestToServer(request);
|
|
}
|
|
|
|
FutureOr<void> sendNotificationToServer(NotificationMessage notification);
|
|
|
|
Future<ResponseMessage> sendRequestToServer(RequestMessage request);
|
|
|
|
void sendResponseToServer(ResponseMessage response);
|
|
|
|
Future<Null> sendShutdown() {
|
|
final request = makeRequest(Method.shutdown, null);
|
|
return expectSuccessfulResponseTo(request, (result) => result);
|
|
}
|
|
|
|
/// Creates a [TextEdit] using the `insert` range of a [InsertReplaceEdit].
|
|
TextEdit textEditForInsert(Either2<TextEdit, InsertReplaceEdit> edit) =>
|
|
edit.map(
|
|
(_) => throw 'Expected InsertReplaceEdit, got TextEdit',
|
|
(e) => TextEdit(range: e.insert, newText: e.newText),
|
|
);
|
|
|
|
/// Creates a [TextEdit] using the `replace` range of a [InsertReplaceEdit].
|
|
TextEdit textEditForReplace(Either2<TextEdit, InsertReplaceEdit> edit) =>
|
|
edit.map(
|
|
(_) => throw 'Expected InsertReplaceEdit, got TextEdit',
|
|
(e) => TextEdit(range: e.replace, newText: e.newText),
|
|
);
|
|
|
|
TextEdit toTextEdit(Either2<TextEdit, InsertReplaceEdit> edit) => edit.map(
|
|
(e) => e,
|
|
(_) => throw 'Expected TextEdit, got InsertReplaceEdit',
|
|
);
|
|
|
|
WorkspaceFolder toWorkspaceFolder(Uri uri) {
|
|
return WorkspaceFolder(
|
|
uri: uri.toString(),
|
|
name: path.basename(uri.path),
|
|
);
|
|
}
|
|
|
|
/// 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 serverToClient.firstWhere((message) {
|
|
if (message is NotificationMessage) {
|
|
if (message.method == CustomMethods.analyzerStatus) {
|
|
if (_clientCapabilities.window?.workDoneProgress == true) {
|
|
throw Exception(
|
|
'Recieved ${CustomMethods.analyzerStatus} notification '
|
|
'but client supports workDoneProgress');
|
|
}
|
|
|
|
final params = AnalyzerStatusParams.fromJson(message.params);
|
|
return params.isAnalyzing == analyzing;
|
|
} else if (message.method == Method.progress) {
|
|
if (_clientCapabilities.window?.workDoneProgress != true) {
|
|
throw Exception(
|
|
'Recieved ${CustomMethods.analyzerStatus} notification '
|
|
'but client supports workDoneProgress');
|
|
}
|
|
|
|
final params = ProgressParams.fromJson(message.params);
|
|
|
|
// Skip unrelated progress notifications.
|
|
if (params.token != analyzingProgressToken) {
|
|
return false;
|
|
}
|
|
|
|
if (params.value is Map<String, dynamic>) {
|
|
final 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 {
|
|
PublishClosingLabelsParams closingLabelsParams;
|
|
await serverToClient.firstWhere((message) {
|
|
if (message is NotificationMessage &&
|
|
message.method == CustomMethods.publishClosingLabels) {
|
|
closingLabelsParams =
|
|
PublishClosingLabelsParams.fromJson(message.params);
|
|
|
|
return closingLabelsParams.uri == uri.toString();
|
|
}
|
|
return false;
|
|
});
|
|
return closingLabelsParams.labels;
|
|
}
|
|
|
|
Future<List<Diagnostic>> waitForDiagnostics(Uri uri) async {
|
|
PublishDiagnosticsParams diagnosticParams;
|
|
await serverToClient.firstWhere((message) {
|
|
if (message is NotificationMessage &&
|
|
message.method == Method.textDocument_publishDiagnostics) {
|
|
diagnosticParams = PublishDiagnosticsParams.fromJson(message.params);
|
|
return diagnosticParams.uri == uri.toString();
|
|
}
|
|
return false;
|
|
}, orElse: () => null);
|
|
return diagnosticParams?.diagnostics;
|
|
}
|
|
|
|
Future<FlutterOutline> waitForFlutterOutline(Uri uri) async {
|
|
PublishFlutterOutlineParams outlineParams;
|
|
await serverToClient.firstWhere((message) {
|
|
if (message is NotificationMessage &&
|
|
message.method == CustomMethods.publishFlutterOutline) {
|
|
outlineParams = PublishFlutterOutlineParams.fromJson(message.params);
|
|
|
|
return outlineParams.uri == uri.toString();
|
|
}
|
|
return false;
|
|
});
|
|
return outlineParams.outline;
|
|
}
|
|
|
|
Future<Outline> waitForOutline(Uri uri) async {
|
|
PublishOutlineParams outlineParams;
|
|
await serverToClient.firstWhere((message) {
|
|
if (message is NotificationMessage &&
|
|
message.method == CustomMethods.publishOutline) {
|
|
outlineParams = PublishOutlineParams.fromJson(message.params);
|
|
|
|
return outlineParams.uri == uri.toString();
|
|
}
|
|
return false;
|
|
});
|
|
return outlineParams.outline;
|
|
}
|
|
|
|
/// Removes markers like `[[` and `]]` and `^` that are used for marking
|
|
/// positions/ranges in strings to avoid hard-coding positions in tests.
|
|
String withoutMarkers(String contents) =>
|
|
contents.replaceAll(allMarkersPattern, '');
|
|
|
|
/// Removes range markers from strings to give accurate position offsets.
|
|
String withoutRangeMarkers(String contents) =>
|
|
contents.replaceAll(rangeMarkerStart, '').replaceAll(rangeMarkerEnd, '');
|
|
|
|
bool Function(List<dynamic>, LspJsonReporter) _canParseList<T>(
|
|
bool Function(Map<String, dynamic>, LspJsonReporter) canParse) =>
|
|
(input, reporter) => input
|
|
.cast<Map<String, dynamic>>()
|
|
.every((item) => canParse(item, reporter));
|
|
|
|
List<T> Function(List<dynamic>) _fromJsonList<T>(
|
|
T Function(Map<String, dynamic>) fromJson) =>
|
|
(input) => input.cast<Map<String, dynamic>>().map(fromJson).toList();
|
|
|
|
Future<void> _handleProgress(NotificationMessage request) async {
|
|
final params = ProgressParams.fromJson(request.params);
|
|
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);
|
|
}
|
|
}
|
|
|
|
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');
|
|
}
|
|
final params = WorkDoneProgressCreateParams.fromJson(request.params);
|
|
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) {
|
|
return notification.method == Method.window_logMessage ||
|
|
notification.method == Method.window_showMessage;
|
|
}
|
|
|
|
/// Creates a `fromJson()` function for an `Either2<T1, T2>` using
|
|
/// the `canParse` and `fromJson` functions for each type.
|
|
static Either2<T1, T2> Function(R) _generateFromJsonFor<T1, T2, R>(
|
|
bool Function(R, LspJsonReporter) canParse1,
|
|
T1 Function(R) fromJson1,
|
|
bool Function(R, LspJsonReporter) canParse2,
|
|
T2 Function(R) fromJson2,
|
|
[LspJsonReporter reporter]) {
|
|
reporter ??= nullLspJsonReporter;
|
|
return (input) {
|
|
if (canParse1(input, reporter)) {
|
|
return Either2<T1, T2>.t1(fromJson1(input));
|
|
}
|
|
if (canParse2(input, reporter)) {
|
|
return Either2<T1, T2>.t2(fromJson2(input));
|
|
}
|
|
throw '$input was not one of ($T1, $T2)';
|
|
};
|
|
}
|
|
}
|