blob: 5c7c905a39c987de98c746f28676e2df533cf411 [file] [log] [blame]
// 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_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/lsp_analysis_server.dart';
import 'package:analysis_server/src/lsp/mapping.dart';
import 'package:analyzer/instrumentation/instrumentation.dart';
import 'package:analyzer/source/line_info.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:meta/meta.dart';
import 'package:test/test.dart';
import '../mocks.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 = new Range(new Position(0, 0), new Position(0, 0));
abstract class AbstractLspAnalysisServerTest
with ResourceProviderMixin, ClientCapabilitiesHelperMixin {
static const positionMarker = '^';
static const rangeMarkerStart = '[[';
static const rangeMarkerEnd = ']]';
static const allMarkers = [positionMarker, rangeMarkerStart, rangeMarkerEnd];
static final allMarkersPattern =
new RegExp(allMarkers.map(RegExp.escape).join('|'));
MockLspServerChannel channel;
LspAnalysisServer server;
int _id = 0;
String projectFolderPath, mainFilePath;
Uri mainFileUri;
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) {
documentChanges.map(
(edits) => applyTextDocumentEdits(fileContents, edits),
(changes) => applyResourceChanges(fileContents, changes),
);
}
void applyResourceChanges(
Map<String, String> oldFileContent,
List<Either4<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>> changes,
) {
// TODO(dantup): Implement handling of resource changes (not currently used).
throw 'Test helper applyResourceChanges not currently supported';
}
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';
}
oldFileContent[path] = applyTextDocumentEdit(oldFileContent[path], edit);
});
}
String applyTextEdit(String content, TextEdit change) {
final startPos = change.range.start;
final endPos = change.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, change.newText);
}
String applyTextEdits(String oldContent, List<TextEdit> changes) {
String 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. However, it is possible
// that multiple edits have the same start position: multiple inserts, or any
// number of inserts followed by a single remove or replace edit. If multiple
// inserts have the same position, the order in the array defines the order in
// which the inserted strings appear in the resulting text.
if (changes.length > 1) {
// TODO(dantup): Implement multi-edit edits.
throw 'Test helper applyTextEdits does not support applying multiple edits';
} else if (changes.length == 1) {
newContent = applyTextEdit(newContent, changes.single);
}
return newContent;
}
Future changeFile(
int newVersion,
Uri uri,
List<TextDocumentContentChangeEvent> changes,
) async {
var notification = makeNotification(
Method.textDocument_didChange,
new DidChangeTextDocumentParams(
new VersionedTextDocumentIdentifier(newVersion, uri.toString()),
changes,
),
);
channel.sendNotificationToServer(notification);
await pumpEventQueue();
}
Future closeFile(Uri uri) async {
var notification = makeNotification(
Method.textDocument_didClose,
new DidCloseTextDocumentParams(
new TextDocumentIdentifier(uri.toString())),
);
channel.sendNotificationToServer(notification);
await pumpEventQueue();
}
Future<Object> executeCommand(Command command) async {
final request = makeRequest(
Method.workspace_executeCommand,
new ExecuteCommandParams(
command.command,
command.arguments,
),
);
return expectSuccessfulResponseTo(request);
}
Future<T> expectErrorNotification<T>(
FutureOr<void> f(), {
Duration timeout = const Duration(seconds: 5),
}) async {
final firstError = channel.errorNotificationsFromServer.first;
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> f(), {
Duration timeout = const Duration(seconds: 5),
}) async {
final firstRequest =
channel.requestsFromServer.firstWhere((n) => n.method == method);
await f();
final requestFromServer = await firstRequest.timeout(timeout);
expect(requestFromServer, isNotNull);
return requestFromServer;
}
/// Sends a request to the server and unwraps the result. Throws if the
/// response was not successful or returned an error.
Future<T> expectSuccessfulResponseTo<T>(RequestMessage request) async {
final resp = await channel.sendRequestToServer(request);
if (resp.error != null) {
throw resp.error;
} else {
return resp.result as T;
}
}
Future<List<TextEdit>> formatDocument(String fileUri) async {
final request = makeRequest(
Method.textDocument_formatting,
new DocumentFormattingParams(
new TextDocumentIdentifier(fileUri),
new FormattingOptions(2, true), // These currently don't do anything
),
);
return expectSuccessfulResponseTo(request);
}
Future<List<TextEdit>> formatOnType(
String fileUri, Position pos, String character) async {
final request = makeRequest(
Method.textDocument_onTypeFormatting,
new DocumentOnTypeFormattingParams(
new TextDocumentIdentifier(fileUri),
pos,
character,
new FormattingOptions(2, true), // These currently don't do anything
),
);
return expectSuccessfulResponseTo(request);
}
Future<List<Either2<Command, CodeAction>>> getCodeActions(
String fileUri, {
Range range,
List<CodeActionKind> kinds,
}) async {
final request = makeRequest(
Method.textDocument_codeAction,
new CodeActionParams(
new TextDocumentIdentifier(fileUri),
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
new CodeActionContext([], kinds)),
);
return expectSuccessfulResponseTo(request);
}
Future<List<CompletionItem>> getCompletion(Uri uri, Position pos,
{CompletionContext context}) async {
final request = makeRequest(
Method.textDocument_completion,
new CompletionParams(
context,
new TextDocumentIdentifier(uri.toString()),
pos,
),
);
return expectSuccessfulResponseTo<List<CompletionItem>>(request);
}
Future<List<Location>> getDefinition(Uri uri, Position pos) async {
final request = makeRequest(
Method.textDocument_definition,
new TextDocumentPositionParams(
new TextDocumentIdentifier(uri.toString()),
pos,
),
);
return expectSuccessfulResponseTo<List<Location>>(request);
}
Future<Either2<List<DocumentSymbol>, List<SymbolInformation>>>
getDocumentSymbols(String fileUri) async {
final request = makeRequest(
Method.textDocument_documentSymbol,
new DocumentSymbolParams(
new TextDocumentIdentifier(fileUri),
),
);
return expectSuccessfulResponseTo(request);
}
Future<Hover> getHover(Uri uri, Position pos) async {
final request = makeRequest(
Method.textDocument_hover,
new TextDocumentPositionParams(
new TextDocumentIdentifier(uri.toString()), pos),
);
return expectSuccessfulResponseTo<Hover>(request);
}
Future<List<Location>> getReferences(
Uri uri,
Position pos, {
includeDeclarations = false,
}) async {
final request = makeRequest(
Method.textDocument_references,
new ReferenceParams(
new ReferenceContext(includeDeclarations),
new TextDocumentIdentifier(uri.toString()),
pos,
),
);
return expectSuccessfulResponseTo<List<Location>>(request);
}
Future<SignatureHelp> getSignatureHelp(Uri uri, Position pos) async {
final request = makeRequest(
Method.textDocument_signatureHelp,
new TextDocumentPositionParams(
new TextDocumentIdentifier(uri.toString()),
pos,
),
);
return expectSuccessfulResponseTo<SignatureHelp>(request);
}
/// 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,
Future<T> f(), {
@required FutureOr<RR> handler(R params),
Duration timeout = const Duration(seconds: 5),
}) async {
FutureOr<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();
});
// Handle the request from the server and send the response back.
final clientsResponse = await handler(incomingRequest.params as R);
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,
TextDocumentClientCapabilities textDocumentCapabilities,
WorkspaceClientCapabilities workspaceCapabilities,
}) async {
final rootUri = Uri.file(rootPath ?? projectFolderPath).toString();
final request = makeRequest(
Method.initialize,
new InitializeParams(
null,
null,
rootUri,
null,
new ClientCapabilities(
workspaceCapabilities,
textDocumentCapabilities,
null,
),
null,
null));
final response = await channel.sendRequestToServer(request);
expect(response.id, equals(request.id));
if (response.error == null) {
final notification = makeNotification(Method.initialized, null);
channel.sendNotificationToServer(notification);
}
return response;
}
NotificationMessage makeNotification(Method method, ToJsonable params) {
return new NotificationMessage(method, params, jsonRpcVersion);
}
RequestMessage makeRequest(Method method, ToJsonable params) {
final id = Either2<num, String>.t1(_id++);
return new RequestMessage(id, method, params, jsonRpcVersion);
}
Future openFile(Uri uri, String content, {num version = 1}) async {
var notification = makeNotification(
Method.textDocument_didOpen,
new DidOpenTextDocumentParams(new TextDocumentItem(
uri.toString(), dartLanguageId, version, content)),
);
channel.sendNotificationToServer(notification);
await pumpEventQueue();
}
Position positionFromMarker(String contents) =>
positionFromOffset(contents.indexOf('^'), contents);
Position positionFromOffset(int offset, String contents) {
final lineInfo = LineInfo.fromContent(contents);
return toPosition(lineInfo.getLocation(offset));
}
/// 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) {
contents = contents.replaceAll(positionMarker, '');
final start = contents.indexOf(rangeMarkerStart);
if (start == -1) {
throw 'Contents did not contain $rangeMarkerStart';
}
final end = contents.indexOf(rangeMarkerEnd);
if (end == -1) {
throw 'Contents did not contain $rangeMarkerEnd';
}
return new Range(
positionFromOffset(start, contents),
positionFromOffset(end - rangeMarkerStart.length, contents),
);
}
Future replaceFile(int newVersion, Uri uri, String content) async {
await changeFile(
newVersion,
uri,
[new TextDocumentContentChangeEvent(null, null, content)],
);
}
/// Sends [responseParams] to the server as a successful response to
/// a server-initiated [request].
void respondTo<T>(RequestMessage request, T responseParams) async {
channel.sendResponseToServer(
new ResponseMessage(request.id, responseParams, null, jsonRpcVersion));
}
void setUp() {
channel = new MockLspServerChannel(debugPrintCommunication);
// Create an SDK in the mock file system.
new MockSdk(resourceProvider: resourceProvider);
server = new LspAnalysisServer(
channel,
resourceProvider,
new AnalysisServerOptions(),
new DartSdkManager(convertPath('/sdk'), false),
InstrumentationService.NULL_SERVICE);
projectFolderPath = convertPath('/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');
mainFileUri = Uri.file(mainFilePath);
}
Future tearDown() async {
channel.close();
await server.shutdown();
}
Future<List<Diagnostic>> waitForDiagnostics(Uri uri) async {
PublishDiagnosticsParams diagnosticParams;
await channel.serverToClient.firstWhere((message) {
if (message is NotificationMessage &&
message.method == Method.textDocument_publishDiagnostics) {
diagnosticParams = message.params;
return diagnosticParams.uri == uri.toString();
}
return false;
});
return diagnosticParams.diagnostics;
}
/// 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, '');
}
mixin ClientCapabilitiesHelperMixin {
final emptyTextDocumentClientCapabilities =
new TextDocumentClientCapabilities(
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null);
final emptyWorkspaceClientCapabilities = new WorkspaceClientCapabilities(
null, null, null, null, null, null, null, null);
TextDocumentClientCapabilities extendTextDocumentCapabilities(
TextDocumentClientCapabilities source,
Map<String, dynamic> textDocumentCapabilities,
) {
final json = source.toJson();
if (textDocumentCapabilities != null) {
textDocumentCapabilities.keys.forEach((key) {
json[key] = textDocumentCapabilities[key];
});
}
return TextDocumentClientCapabilities.fromJson(json);
}
WorkspaceClientCapabilities extendWorkspaceCapabilities(
WorkspaceClientCapabilities source,
Map<String, dynamic> workspaceCapabilities,
) {
final json = source.toJson();
if (workspaceCapabilities != null) {
workspaceCapabilities.keys.forEach((key) {
json[key] = workspaceCapabilities[key];
});
}
return WorkspaceClientCapabilities.fromJson(json);
}
TextDocumentClientCapabilities withCodeActionKinds(
TextDocumentClientCapabilities source,
List<CodeActionKind> kinds,
) {
return extendTextDocumentCapabilities(source, {
'codeAction': {
'codeActionLiteralSupport': {
'codeActionKind': {'valueSet': kinds.map((k) => k.toJson()).toList()}
}
}
});
}
TextDocumentClientCapabilities withCompletionItemDeprecatedSupport(
TextDocumentClientCapabilities source,
) {
return extendTextDocumentCapabilities(source, {
'completion': {
'completionItem': {'deprecatedSupport': true}
}
});
}
TextDocumentClientCapabilities withCompletionItemSnippetSupport(
TextDocumentClientCapabilities source,
) {
return extendTextDocumentCapabilities(source, {
'completion': {
'completionItem': {'snippetSupport': true}
}
});
}
TextDocumentClientCapabilities withCompletionItemKinds(
TextDocumentClientCapabilities source,
List<CompletionItemKind> kinds,
) {
return extendTextDocumentCapabilities(source, {
'completion': {
'completionItemKind': {
'valueSet': kinds.map((k) => k.toJson()).toList()
}
}
});
}
TextDocumentClientCapabilities withHoverContentFormat(
TextDocumentClientCapabilities source,
List<MarkupKind> formats,
) {
return extendTextDocumentCapabilities(source, {
'hover': {'contentFormat': formats.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 withHierarchicalDocumentSymbolSupport(
TextDocumentClientCapabilities source,
) {
return extendTextDocumentCapabilities(source, {
'documentSymbol': {'hierarchicalDocumentSymbolSupport': true}
});
}
WorkspaceClientCapabilities withDocumentChangesSupport(
WorkspaceClientCapabilities source,
) {
return extendWorkspaceCapabilities(source, {
'workspaceEdit': {'documentChanges': true}
});
}
}