Files
sdk/pkg/analysis_server/test/lsp/request_helpers_mixin.dart
T
Danny Tuppeny 336ad52179 [analysis_server] Fix handling of default values in unanswered Interactive Forms
This fixes a bug where an interactive form where all fields have defaults would be considered complete immediately, so we'd never present the fields to the client.

It also includes some minor refactoring extracted from a future CL that implements command/resolve and supports Interactive Forms in refactors in an attempt to reduce the size of that change to aid reviewing.

Change-Id: I176fe25dbb0b610d69617fa04562b0d3ce571642
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/508220
Reviewed-by: Brian Wilkerson <brianwilkerson@google.com>
Reviewed-by: Samuel Rawlins <srawlins@google.com>
2026-06-01 12:43:19 -07:00

1501 lines
48 KiB
Dart

// Copyright (c) 2023, 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/lsp/client_capabilities.dart';
import 'package:analysis_server/src/lsp/constants.dart';
import 'package:analysis_server/src/lsp/extensions/positions.dart';
import 'package:analysis_server/src/lsp/mapping.dart';
import 'package:analysis_server/src/services/completion/dart/feature_computer.dart';
import 'package:analyzer/source/line_info.dart';
import 'package:analyzer_plugin/src/utilities/client_uri_converter.dart';
import 'package:collection/collection.dart';
import 'package:language_server_protocol/json_parsing.dart';
import 'package:path/path.dart' as path;
import 'package:test/test.dart' as test show expect;
import 'package:test/test.dart';
import '../utils/lsp_protocol_extensions.dart';
import 'change_verifier.dart';
import 'server_abstract.dart';
/// A mixin with helpers for applying LSP edits to strings.
mixin LspEditHelpersMixin {
String applyTextEdits(String content, List<TextEdit> changes) {
// 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.
for (var change1 in changes) {
for (var change2 in changes) {
if (change1 != change2 && change1.range.intersects(change2.range)) {
throw 'Test helper applyTextEdits does not support applying multiple edits '
'where the edits are not in reverse order.';
}
}
}
var indexedEdits = changes.mapIndexed(TextEditWithIndex.new).toList();
indexedEdits.sort(TextEditWithIndex.compare);
var lineInfo = LineInfo.fromContent(content);
var buffer = StringBuffer();
var currentOffset = 0;
// Build a new string by appending parts of the original string and then
// the new text from each edit into a buffer. This is faster for multiple
// edits than sorting in reverse and repeatedly applying sequentially as it
// cuts out a lot of (potentially large) intermediate strings.
for (var edit in indexedEdits.map((e) => e.edit)) {
var startPos = edit.range.start;
var endPos = edit.range.end;
var start = lineInfo.getOffsetOfLine(startPos.line) + startPos.character;
var end = lineInfo.getOffsetOfLine(endPos.line) + endPos.character;
// Append any text between the current position and the start of this edit
if (start != currentOffset) {
buffer.write(content.substring(currentOffset, start));
}
// Append the replacement text
buffer.write(edit.newText);
// Move the current position to the end of this edit
currentOffset = end;
}
// Finally, add any remainder from after the last edit.
if (currentOffset != content.length) {
buffer.write(content.substring(currentOffset));
}
return buffer.toString();
}
/// Returns the text for [range] in [content].
String getTextForRange(String content, Range range) {
var lineInfo = LineInfo.fromContent(content);
var sourceRange = toSourceRange(lineInfo, range).result;
return content.substring(sourceRange.offset, sourceRange.end);
}
}
/// The kind of IDs that can be used for messages.
enum LspMessageIdMode { integer, string }
mixin LspNotificationsMixin {
/// Whether to fail tests if any error notifications are received from the
/// server.
///
/// This is set automatically when using [expectErrorNotification].
var failTestOnAnyErrorNotification = true;
/// A stream of [NotificationMessage]s from the server that may be errors.
Stream<NotificationMessage> get errorNotificationsFromServer {
return notificationsFromServer.where(_isErrorNotification);
}
Stream<NotificationMessage> get notificationsFromServer;
/// A stream of strings (CREATE, BEGIN, END) corresponding to progress
/// requests and notifications for convenience in testing.
///
/// Analyzing statuses are not included.
Stream<String> get progressUpdates {
var controller = StreamController<String>();
requestsFromServer
.where((r) => r.method == Method.window_workDoneProgress_create)
.listen((request) {
var params = WorkDoneProgressCreateParams.fromJson(
request.params as Map<String, Object?>,
);
if (params.token != analyzingProgressToken) {
controller.add('CREATE');
}
}, onDone: controller.close);
notificationsFromServer.where((n) => n.method == Method.progress).listen((
notification,
) {
var params = ProgressParams.fromJson(
notification.params as Map<String, Object?>,
);
if (params.token != analyzingProgressToken) {
if (WorkDoneProgressBegin.canParse(params.value, nullLspJsonReporter)) {
controller.add('BEGIN');
} else if (WorkDoneProgressEnd.canParse(
params.value,
nullLspJsonReporter,
)) {
controller.add('END');
}
}
});
return controller.stream;
}
Stream<RequestMessage> get requestsFromServer;
/// Calls [f] and expects an error notification (`window/showMessage`) within
/// 5s.
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?>,
);
}
/// Calls [f] and expects a notification matching [test] within 5s.
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;
}
/// 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;
}
}
}
/// Helpers to simplify building LSP requests for use in tests.
///
/// The actual sending of requests must be supplied by the implementing class
/// via [expectSuccessfulResponseTo].
///
/// These helpers can be used by in-process tests and out-of-process integration
/// tests and by both the native LSP server and using LSP over the legacy
/// protocol.
mixin LspRequestHelpersMixin {
int _id = 0;
final startOfDocPos = Position(line: 0, character: 0);
final startOfDocRange = Range(
start: Position(line: 0, character: 0),
end: Position(line: 0, character: 0),
);
/// Whether to include 'clientRequestTime' fields in outgoing messages.
bool includeClientRequestTime = false;
/// 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');
/// The kind of IDs to use for outbound (client-to-server) messages.
LspMessageIdMode get idMode => LspMessageIdMode.integer;
Future<List<CallHierarchyIncomingCall>?> callHierarchyIncoming(
CallHierarchyItem item,
) {
var request = makeRequest(
Method.callHierarchy_incomingCalls,
CallHierarchyIncomingCallsParams(item: item),
);
return expectSuccessfulResponseTo(
request,
_fromJsonList(CallHierarchyIncomingCall.fromJson),
);
}
Future<List<CallHierarchyOutgoingCall>?> callHierarchyOutgoing(
CallHierarchyItem item,
) {
var request = makeRequest(
Method.callHierarchy_outgoingCalls,
CallHierarchyOutgoingCallsParams(item: item),
);
return expectSuccessfulResponseTo(
request,
_fromJsonList(CallHierarchyOutgoingCall.fromJson),
);
}
Future<Null> connectToDtd(Uri uri, {bool? registerExperimentalHandlers}) {
var request = makeRequest(
CustomMethods.connectToDtd,
ConnectToDtdParams(
uri: uri,
registerExperimentalHandlers: registerExperimentalHandlers,
),
);
return expectSuccessfulResponseTo<Null, Null>(request, (Null n) => n);
}
Future<Null> editArgument(Uri uri, Position pos, ArgumentEdit edit) {
var request = makeRequest(
CustomMethods.dartTextDocumentEditArgument,
EditArgumentParams(
textDocument: TextDocumentIdentifier(uri: uri),
position: pos,
edit: edit,
),
);
return expectSuccessfulResponseTo(request, (Null n) => n);
}
/// Gets the entire range for [code].
Range entireRange(String code) =>
Range(start: startOfDocPos, end: positionFromOffset(code.length, code));
Future<T> executeCommand<T>(
Command command, {
T Function(Map<String, Object?>)? decoder,
ProgressToken? workDoneToken,
}) {
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,
);
}
void expect(Object? actual, Object? matcher, {String? reason}) {
test.expect(actual, matcher, reason: reason);
}
Future<T> expectSuccessfulResponseTo<T, R>(
RequestMessage request,
T Function(R) fromJson,
);
Future<List<TextEdit>?> formatDocument(Uri fileUri) {
var 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(
Uri fileUri,
Position pos,
String character,
) {
var 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(Uri fileUri, Range range) {
var 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<Location?> getAugmentation(Uri uri, Position pos) {
var request = makeRequest(
CustomMethods.augmentation,
TextDocumentPositionParams(
textDocument: TextDocumentIdentifier(uri: uri),
position: pos,
),
);
return expectSuccessfulResponseTo(request, Location.fromJson);
}
Future<Location?> getAugmented(Uri uri, Position pos) {
var request = makeRequest(
CustomMethods.augmented,
TextDocumentPositionParams(
textDocument: TextDocumentIdentifier(uri: uri),
position: pos,
),
);
return expectSuccessfulResponseTo(request, Location.fromJson);
}
Future<List<CodeAction>> getCodeActions(
Uri fileUri, {
Range? range,
Position? position,
List<CodeActionKind>? kinds,
CodeActionTriggerKind? triggerKind,
ProgressToken? workDoneToken,
}) async {
range ??= position != null
? Range(start: position, end: position)
: throw 'Supply either a Range or Position for CodeActions requests';
var request = makeRequest(
Method.textDocument_codeAction,
CodeActionParams(
textDocument: TextDocumentIdentifier(uri: fileUri),
range: range,
context: CodeActionContext(
// 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
diagnostics: [],
only: kinds,
triggerKind: triggerKind,
),
workDoneToken: workDoneToken,
),
);
var actions = await expectSuccessfulResponseTo(
request,
_fromJsonList(
_generateFromJsonFor(
CodeActionLiteral.canParse,
CodeActionLiteral.fromJson,
Command.canParse,
Command.fromJson,
),
),
);
// As an additional check, ensure all returned values are either exact
// matches or sub-kinds of the requested kind(s).
if (kinds != null && kinds.isNotEmpty) {
// Kinds must either by an exact match, or start with the
// requested value followed by a dot (a sub-kind).
var allowedKinds = kinds
.expand((kind) => [equals('$kind'), startsWith('$kind.')])
.toList();
// Only CodeActionLiterals can be checked because bare commands do not
// have CodeActionKinds (once they've left the server).
var literals = actions.where((action) => action.isCodeActionLiteral);
for (var result in literals) {
expect(result.asCodeActionLiteral.kind.toString(), anyOf(allowedKinds));
}
}
return actions;
}
Future<TextDocumentCodeLensResult> getCodeLens(Uri uri) {
var request = makeRequest(
Method.textDocument_codeLens,
CodeLensParams(textDocument: TextDocumentIdentifier(uri: uri)),
);
return expectSuccessfulResponseTo(
request,
_fromJsonList(CodeLens.fromJson),
);
}
Future<List<ColorPresentation>> getColorPresentation(
Uri fileUri,
Range range,
Color color,
) {
var request = makeRequest(
Method.textDocument_colorPresentation,
ColorPresentationParams(
textDocument: TextDocumentIdentifier(uri: fileUri),
range: range,
color: color,
),
);
return expectSuccessfulResponseTo(
request,
_fromJsonList(ColorPresentation.fromJson),
);
}
Future<List<CompletionItem>> getCompletion(
Uri uri,
Position pos, {
CompletionContext? context,
}) async {
var response = await getCompletionList(uri, pos, context: context);
return response.items;
}
Future<CompletionList> getCompletionList(
Uri uri,
Position pos, {
CompletionContext? context,
}) async {
var request = makeRequest(
Method.textDocument_completion,
CompletionParams(
context: context,
textDocument: TextDocumentIdentifier(uri: uri),
position: pos,
),
);
var completions = await expectSuccessfulResponseTo(
request,
CompletionList.fromJson,
);
_assertMinimalCompletionListPayload(completions);
return completions;
}
Future<Either2<List<Location>, List<LocationLink>>> getDefinition(
Uri uri,
Position pos,
) {
var request = makeRequest(
Method.textDocument_definition,
TextDocumentPositionParams(
textDocument: TextDocumentIdentifier(uri: uri),
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 {
var 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 {
var results = await getDefinition(uri, pos);
return results.map(
(locations) => throw 'Expected List<LocationLink> got List<Location>',
(locationLinks) => locationLinks,
);
}
Future<DartDiagnosticServer> getDiagnosticServer() {
var request = makeRequest(CustomMethods.diagnosticServer, null);
return expectSuccessfulResponseTo(request, DartDiagnosticServer.fromJson);
}
Future<List<ColorInformation>> getDocumentColors(Uri fileUri) {
var request = makeRequest(
Method.textDocument_documentColor,
DocumentColorParams(textDocument: TextDocumentIdentifier(uri: fileUri)),
);
return expectSuccessfulResponseTo(
request,
_fromJsonList(ColorInformation.fromJson),
);
}
Future<List<DocumentHighlight>?> getDocumentHighlights(
Uri uri,
Position pos,
) {
var request = makeRequest(
Method.textDocument_documentHighlight,
TextDocumentPositionParams(
textDocument: TextDocumentIdentifier(uri: uri),
position: pos,
),
);
return expectSuccessfulResponseTo(
request,
_fromJsonList(DocumentHighlight.fromJson),
);
}
Future<List<DocumentLink>?> getDocumentLinks(Uri uri) {
var request = makeRequest(
Method.textDocument_documentLink,
DocumentLinkParams(textDocument: TextDocumentIdentifier(uri: uri)),
);
return expectSuccessfulResponseTo(
request,
_fromJsonList(DocumentLink.fromJson),
);
}
Future<Either2<List<DocumentSymbol>, List<SymbolInformation>>>
getDocumentSymbols(Uri uri) {
var request = makeRequest(
Method.textDocument_documentSymbol,
DocumentSymbolParams(textDocument: TextDocumentIdentifier(uri: uri)),
);
return expectSuccessfulResponseTo(
request,
_generateFromJsonFor(
_canParseList(DocumentSymbol.canParse),
_fromJsonList(DocumentSymbol.fromJson),
_canParseList(SymbolInformation.canParse),
_fromJsonList(SymbolInformation.fromJson),
),
);
}
Future<EditableArguments?> getEditableArguments(Uri uri, Position pos) {
var request = makeRequest(
CustomMethods.dartTextDocumentEditableArguments,
TextDocumentPositionParams(
textDocument: TextDocumentIdentifier(uri: uri),
position: pos,
),
);
return expectSuccessfulResponseTo(request, EditableArguments.fromJson);
}
Future<List<FoldingRange>> getFoldingRanges(Uri uri) {
var request = makeRequest(
Method.textDocument_foldingRange,
FoldingRangeParams(textDocument: TextDocumentIdentifier(uri: uri)),
);
return expectSuccessfulResponseTo(
request,
_fromJsonList(FoldingRange.fromJson),
);
}
Future<Hover?> getHover(Uri uri, Position pos) {
var request = makeRequest(
Method.textDocument_hover,
TextDocumentPositionParams(
textDocument: TextDocumentIdentifier(uri: uri),
position: pos,
),
);
return expectSuccessfulResponseTo(request, Hover.fromJson);
}
Future<List<Location>> getImplementations(
Uri uri,
Position pos, {
includeDeclarations = false,
}) {
var request = makeRequest(
Method.textDocument_implementation,
TextDocumentPositionParams(
textDocument: TextDocumentIdentifier(uri: uri),
position: pos,
),
);
return expectSuccessfulResponseTo(
request,
_fromJsonList(Location.fromJson),
);
}
Future<List<Location>?> getImports(Uri uri, Position pos) {
var request = makeRequest(
CustomMethods.imports,
TextDocumentPositionParams(
textDocument: TextDocumentIdentifier(uri: uri),
position: pos,
),
);
return expectSuccessfulResponseTo(
request,
_fromJsonList(Location.fromJson),
);
}
Future<List<InlayHint>> getInlayHints(Uri uri, Range range) {
var request = makeRequest(
Method.textDocument_inlayHint,
InlayHintParams(
textDocument: TextDocumentIdentifier(uri: uri),
range: range,
),
);
return expectSuccessfulResponseTo(
request,
_fromJsonList(InlayHint.fromJson),
);
}
Future<List<InlineValue>?> getInlineValues(
Uri uri, {
required Range visibleRange,
required Position stoppedAt,
}) {
var request = makeRequest(
Method.textDocument_inlineValue,
InlineValueParams(
textDocument: TextDocumentIdentifier(uri: uri),
range: visibleRange,
context: InlineValueContext(
frameId: 0,
stoppedLocation: Range(start: stoppedAt, end: stoppedAt),
),
),
);
return expectSuccessfulResponseTo(
request,
// Use a custom function to deserialise the response into the correct
// kind of `InlineValue`.
_fromJsonList((Map<String, Object?> input) {
var reporter = nullLspJsonReporter;
// The overlap of these types is such that a Variable or Text would also
// match `InlineValueEvaluatableExpression.canParse` (because
// `expression` is optional), so the order of calling canParse here
// matters.
if (InlineValueVariableLookup.canParse(input, reporter)) {
return InlineValue.t3(InlineValueVariableLookup.fromJson(input));
}
if (InlineValueText.canParse(input, reporter)) {
return InlineValue.t2(InlineValueText.fromJson(input));
}
if (InlineValueEvaluatableExpression.canParse(input, reporter)) {
return InlineValue.t1(
InlineValueEvaluatableExpression.fromJson(input),
);
}
throw 'InlineValue did not match any valid types';
}),
);
}
Future<List<SelectionRange>?> getManySelectionRanges(
Uri uri,
List<Position> positions,
) {
var request = makeRequest(
Method.textDocument_selectionRange,
SelectionRangeParams(
textDocument: TextDocumentIdentifier(uri: uri),
positions: positions,
),
);
return expectSuccessfulResponseTo(
request,
_fromJsonList(SelectionRange.fromJson),
);
}
Future<List<Location>> getReferences(
Uri uri,
Position pos, {
bool includeDeclarations = false,
}) {
var request = makeRequest(
Method.textDocument_references,
ReferenceParams(
context: ReferenceContext(includeDeclaration: includeDeclarations),
textDocument: TextDocumentIdentifier(uri: uri),
position: pos,
),
);
return expectSuccessfulResponseTo(
request,
_fromJsonList(Location.fromJson),
);
}
Future<CompletionItem> getResolvedCompletion(
Uri uri,
Position pos,
String label, {
CompletionContext? context,
}) async {
var completions = await getCompletion(uri, pos, context: context);
var completion = completions.singleWhere((c) => c.label == label);
expect(completion, isNotNull);
var result = await resolveCompletion(completion);
_assertMinimalCompletionItemPayload(result);
return result;
}
Future<SelectionRange> getSelectionRanges(Uri uri, Position position) async {
var result = await getManySelectionRanges(uri, [position]);
return result!.single;
}
Future<SemanticTokens> getSemanticTokens(Uri uri) {
var request = makeRequest(
Method.textDocument_semanticTokens_full,
SemanticTokensParams(textDocument: TextDocumentIdentifier(uri: uri)),
);
return expectSuccessfulResponseTo(request, SemanticTokens.fromJson);
}
Future<SemanticTokens> getSemanticTokensRange(Uri uri, Range range) {
var request = makeRequest(
Method.textDocument_semanticTokens_range,
SemanticTokensRangeParams(
textDocument: TextDocumentIdentifier(uri: uri),
range: range,
),
);
return expectSuccessfulResponseTo(request, SemanticTokens.fromJson);
}
Future<SignatureHelp?> getSignatureHelp(
Uri uri,
Position pos, [
SignatureHelpContext? context,
]) {
var request = makeRequest(
Method.textDocument_signatureHelp,
SignatureHelpParams(
textDocument: TextDocumentIdentifier(uri: uri),
position: pos,
context: context,
),
);
return expectSuccessfulResponseTo(request, SignatureHelp.fromJson);
}
Future<DocumentSummary> getSummary(Uri uri) {
var request = makeRequest(
CustomMethods.summary,
DartTextDocumentSummaryParams(uri: uri),
);
return expectSuccessfulResponseTo(request, DocumentSummary.fromJson);
}
Future<Location> getSuper(Uri uri, Position pos) {
var request = makeRequest(
CustomMethods.super_,
TextDocumentPositionParams(
textDocument: TextDocumentIdentifier(uri: uri),
position: pos,
),
);
return expectSuccessfulResponseTo(request, Location.fromJson);
}
Future<TextDocumentTypeDefinitionResult> getTypeDefinition(
Uri uri,
Position pos,
) {
var request = makeRequest(
Method.textDocument_typeDefinition,
TypeDefinitionParams(
textDocument: TextDocumentIdentifier(uri: uri),
position: pos,
),
);
// TextDocumentTypeDefinitionResult is a nested Either, so we need to handle
// nested fromJson/canParse here.
// TextDocumentTypeDefinitionResult: Either2<Definition, List<DefinitionLink>>?
// Definition: Either2<List<Location>, Location>
// Definition = Either2<List<Location>, Location>
var definitionCanParse = _generateCanParseFor(
_canParseList(Location.canParse),
Location.canParse,
);
var definitionFromJson = _generateFromJsonFor(
_canParseList(Location.canParse),
_fromJsonList(Location.fromJson),
Location.canParse,
Location.fromJson,
);
return expectSuccessfulResponseTo(
request,
_generateFromJsonFor(
definitionCanParse,
definitionFromJson,
_canParseList(DefinitionLink.canParse),
_fromJsonList(DefinitionLink.fromJson),
),
);
}
Future<List<Location>> getTypeDefinitionAsLocation(
Uri uri,
Position pos,
) async {
var results = (await getTypeDefinition(uri, pos))!;
return results.map(
(locationOrList) => locationOrList.map(
(locations) => locations,
(location) => [location],
),
(locationLinks) => throw 'Expected Locations, got LocationLinks',
);
}
Future<List<LocationLink>> getTypeDefinitionAsLocationLinks(
Uri uri,
Position pos,
) async {
var results = (await getTypeDefinition(uri, pos))!;
return results.map(
(locationOrList) => throw 'Expected LocationLinks, got Locations',
(locationLinks) => locationLinks,
);
}
Future<List<SymbolInformation>> getWorkspaceSymbols(String query) {
var request = makeRequest(
Method.workspace_symbol,
WorkspaceSymbolParams(query: query),
);
return expectSuccessfulResponseTo(
request,
_fromJsonList(SymbolInformation.fromJson),
);
}
RequestMessage makeRequest(Method method, Object? params) {
var id = _getNextId();
return RequestMessage(
id: id,
method: method,
params: params is ToJsonable ? params.toJson() : params,
jsonrpc: jsonRpcVersion,
clientRequestTime: includeClientRequestTime
? DateTime.now().millisecondsSinceEpoch
: null,
);
}
Future<WorkspaceEdit> onWillRename(List<FileRename> renames) {
var request = makeRequest(
Method.workspace_willRenameFiles,
RenameFilesParams(files: renames),
);
return expectSuccessfulResponseTo(request, WorkspaceEdit.fromJson);
}
Position positionFromOffset(int offset, String contents) {
var lineInfo = LineInfo.fromContent(contents);
return toPosition(lineInfo.getLocation(offset));
}
Future<List<CallHierarchyItem>?> prepareCallHierarchy(Uri uri, Position pos) {
var request = makeRequest(
Method.textDocument_prepareCallHierarchy,
CallHierarchyPrepareParams(
textDocument: TextDocumentIdentifier(uri: uri),
position: pos,
),
);
return expectSuccessfulResponseTo(
request,
_fromJsonList(CallHierarchyItem.fromJson),
);
}
Future<PrepareRenamePlaceholder?> prepareRename(Uri uri, Position pos) {
var request = makeRequest(
Method.textDocument_prepareRename,
TextDocumentPositionParams(
textDocument: TextDocumentIdentifier(uri: uri),
position: pos,
),
);
return expectSuccessfulResponseTo(
request,
PrepareRenamePlaceholder.fromJson,
);
}
Future<List<TypeHierarchyItem>?> prepareTypeHierarchy(Uri uri, Position pos) {
var request = makeRequest(
Method.textDocument_prepareTypeHierarchy,
TypeHierarchyPrepareParams(
textDocument: TextDocumentIdentifier(uri: uri),
position: pos,
),
);
return expectSuccessfulResponseTo(
request,
_fromJsonList(TypeHierarchyItem.fromJson),
);
}
Future<CompletionItem> resolveCompletion(CompletionItem item) {
var request = makeRequest(Method.completionItem_resolve, item);
return expectSuccessfulResponseTo(request, CompletionItem.fromJson);
}
Future<ResponseMessage> sendRequestToServer(RequestMessage request);
Future<List<TypeHierarchyItem>?> typeHierarchySubtypes(
TypeHierarchyItem item,
) {
var request = makeRequest(
Method.typeHierarchy_subtypes,
TypeHierarchySubtypesParams(item: item),
);
return expectSuccessfulResponseTo(
request,
_fromJsonList(TypeHierarchyItem.fromJson),
);
}
Future<List<TypeHierarchyItem>?> typeHierarchySupertypes(
TypeHierarchyItem item,
) {
var request = makeRequest(
Method.typeHierarchy_supertypes,
TypeHierarchySupertypesParams(item: item),
);
return expectSuccessfulResponseTo(
request,
_fromJsonList(TypeHierarchyItem.fromJson),
);
}
Future<void> updateDiagnosticInformation(Map<String, Object?>? params) {
var request = makeRequest(
CustomMethods.updateDiagnosticInformation,
params,
);
return expectSuccessfulResponseTo(request, (Null n) => n);
}
/// A helper that performs some checks on a completion sent back during
/// tests to check for any unnecessary data in the payload that could be
/// reduced.
void _assertMinimalCompletionItemPayload(CompletionItem completion) {
var labelDetails = completion.labelDetails;
var textEditInsertRange = completion.textEdit?.map(
(ranges) => ranges.insert,
(range) => null,
);
var textEditReplaceRange = completion.textEdit?.map(
(ranges) => ranges.replace,
(range) => null,
);
var sortText = completion.sortText;
// Check fields that default to label if not supplied.
void expectNotLabel(String? value, String name) {
expect(
value,
isNot(completion.label),
reason: '$name should not be set if same as label',
);
}
expectNotLabel(completion.insertText, 'insertText');
expectNotLabel(completion.filterText, 'filterText');
expectNotLabel(completion.sortText, 'sortText');
expectNotLabel(completion.textEditText, 'textEditText');
// If we have separate insert/replace ranges, they should not be the same.
if (textEditInsertRange != null) {
expect(textEditReplaceRange, isNot(textEditInsertRange));
}
// Check for empty labelDetails.
if (labelDetails != null) {
expect(
labelDetails.description != null || labelDetails.detail != null,
isTrue,
reason: 'labelDetails should be null if all fields are null',
);
}
// Check for empty lists.
void expectNotEmpty(Object? value, String name) {
expect(
value,
anyOf(isNull, isNotEmpty),
reason: '$name should be null if no items',
);
}
expectNotEmpty(completion.additionalTextEdits, 'additionalTextEdits');
expectNotEmpty(completion.commitCharacters, 'commitCharacters');
expectNotEmpty(completion.tags, 'tags');
// We convert numeric relevance scores into text because LSP uses text
// for sorting. We never need to use more digits in the sortText than there
// are in the maximum relevance for this.
//
// Only do this for sort texts that are numeric because we also use zzz
// prefixes with text for snippets.
if (sortText != null && int.tryParse(sortText) != null) {
expect(
sortText.length,
lessThanOrEqualTo(maximumRelevance.toString().length),
reason:
'sortText never needs to have more characters '
'than "$maximumRelevance" (${maximumRelevance.bitLength})',
);
}
}
/// A helper that performs some checks on all completions sent back during
/// tests to check for any unnecessary data in the payload that could be
/// reduced.
void _assertMinimalCompletionListPayload(CompletionList completions) {
for (var completion in completions.items) {
var data = completion.data;
var commitCharacters = completion.commitCharacters;
var insertRange = completion.textEdit?.map(
(insertReplace) => insertReplace.insert,
(textEdit) => null,
);
var replaceRange = completion.textEdit?.map(
(insertReplace) => insertReplace.replace,
(textEdit) => null,
);
var combinedRange = completion.textEdit?.map(
(insertReplace) => null,
(textEdit) => textEdit.range,
);
var insertTextFormat = completion.insertTextFormat;
var insertTextMode = completion.insertTextMode;
var defaults = completions.itemDefaults;
var defaultInsertRange = defaults?.editRange?.map(
(editRange) => editRange.insert,
(range) => null,
);
var defaultReplaceRange = defaults?.editRange?.map(
(editRange) => editRange.replace,
(range) => null,
);
var defaultCombinedRange = defaults?.editRange?.map(
(editRange) => null,
(range) => range,
);
_assertMinimalCompletionItemPayload(completion);
// Check for fields matching defaults.
if (defaults != null) {
void expectNotDefault(Object? value, Object? default_, String name) {
if (value == null) return;
expect(
value,
isNot(default_),
reason: '$name should be omitted if same as default',
);
}
expectNotDefault(data?.toJson(), defaults.data, 'data');
expectNotDefault(
commitCharacters,
defaults.commitCharacters,
'commitCharacters',
);
expectNotDefault(insertRange, defaultInsertRange, 'insertRange');
expectNotDefault(replaceRange, defaultReplaceRange, 'replaceRange');
expectNotDefault(combinedRange, defaultCombinedRange, 'combined range');
expectNotDefault(
insertTextFormat,
defaults.insertTextFormat,
'insertTextFormat',
);
expectNotDefault(
insertTextMode,
defaults.insertTextMode,
'insertTextMode',
);
}
// If we have separate insert/replace ranges, they should not be the same.
if (defaultInsertRange != null) {
expect(defaultReplaceRange, isNot(defaultInsertRange));
}
}
}
bool Function(Object?, LspJsonReporter) _canParseList<T>(
bool Function(Map<String, Object?>, LspJsonReporter) canParse,
) =>
(input, reporter) =>
input is List &&
input.cast<Map<String, Object?>>().every(
(item) => canParse(item, reporter),
);
List<T> Function(List<Object?>) _fromJsonList<T>(
T Function(Map<String, Object?>) fromJson,
) =>
(input) => input.cast<Map<String, Object?>>().map(fromJson).toList();
/// Gets an ID to use for an outbound request, and increases the internal
/// counter for the next one.
Either2<int, String> _getNextId() {
return switch (idMode) {
.integer => Either2<int, String>.t1(_id++),
.string => Either2<int, String>.t2('${_id++}'),
};
}
/// Creates a `canParse()` function for an `Either2<T1, T2>` using
/// the `canParse` function for each type.
static bool Function(Object?, LspJsonReporter) _generateCanParseFor<T1, T2>(
bool Function(Object?, LspJsonReporter) canParse1,
bool Function(Object?, LspJsonReporter) canParse2,
) {
return (input, reporter) =>
canParse1(input, reporter) || canParse2(input, reporter);
}
/// Creates a `fromJson()` function for an `Either2<T1, T2>` using
/// the `canParse` and `fromJson` functions for each type.
static Either2<T1, T2> Function(Object?) _generateFromJsonFor<T1, T2, R1, R2>(
bool Function(Object?, LspJsonReporter) canParse1,
T1 Function(R1) fromJson1,
bool Function(Object?, LspJsonReporter) canParse2,
T2 Function(R2) fromJson2, [
LspJsonReporter? reporter,
]) {
reporter ??= nullLspJsonReporter;
return (input) {
reporter!;
if (canParse1(input, reporter)) {
return Either2<T1, T2>.t1(fromJson1(input as R1));
}
if (canParse2(input, reporter)) {
return Either2<T1, T2>.t2(fromJson2(input as R2));
}
throw '$input was not one of ($T1, $T2)';
};
}
}
/// Helpers to simplify handling LSP reverse-requests.
///
/// The sending of responses must be supplied by the implementing class
/// via [sendResponseToServer].
mixin LspReverseRequestHelpersMixin {
/// A stream of reverse-requests from the server that can be responded to via
/// [sendResponseToServer].
///
/// Only LSP message requests (`lsp.handle`) from the server are included
/// here.
Stream<RequestMessage> get requestsFromServer;
/// 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 {
var firstRequest = requestsFromServer.firstWhere((n) => n.method == method);
await f();
var requestFromServer = await firstRequest.timeout(
timeout,
onTimeout: () => throw TimeoutException(
'Did not receive the expected $method request from the server in the timeout period',
timeout,
),
);
expect(requestFromServer, isNotNull);
return requestFromServer;
}
/// 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 received 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 Future<RR> Function(R) handler,
Duration timeout = const Duration(seconds: 5),
}) {
late Future<T> outboundRequest;
Object? outboundRequestError;
// Execute [f] to start the outbound request that will trigger the inbound
// request.
var incomingRequestFuture =
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 even if expected.
// Instead, capture the error and suppress it. But if we time out (in
// which case we will never return outboundRequest), then we'll raise this
// error.
outboundRequest.then(
(_) {},
onError: (e) {
outboundRequestError =
'Expected to handle a $method reverse-request but no such request arrived. '
'However, an outbound request returned an error that may be the cause: $e';
return null;
},
);
}, timeout: timeout).catchError((Object timeoutException) {
// We timed out waiting for the request from the server. Probably this is
// because our outbound request for some reason, so if we have an error
// for that, then throw it. Otherwise, propogate the timeout.
throw outboundRequestError ?? timeoutException;
}, test: (e) => e is TimeoutException);
// When the inbound request arrives, send the response back. Don't wait for
// it here because some tests handle requests that complete early (eg.
// cancellation) and need to just await the outbound request.
unawaited(
incomingRequestFuture.then((incomingRequest) async {
// Call the handler to compute the repsonse.
var clientsResponse = await handler(
fromJson(incomingRequest.params as Map<String, Object?>),
);
// Send the repsonse back to the server.
respondTo(incomingRequest, clientsResponse);
}),
);
// Return a future that completes when the response to the original request
// (from [f]) returns.
return outboundRequest;
}
/// 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,
),
);
}
/// Sends a ResponseMessage to the server, completing a reverse
/// (server-to-client) request.
void sendResponseToServer(ResponseMessage response);
}
/// A mixin with helpers for verifying LSP edits in a given project.
///
/// Extends [LspEditHelpersMixin] with methods for accessing file state and
/// information about the project to build paths.
mixin LspVerifyEditHelpersMixin
on LspEditHelpersMixin, ClientCapabilitiesHelperMixin {
LspClientCapabilities get editorClientCapabilities => LspClientCapabilities(
ClientCapabilities(
workspace: workspaceCapabilities,
textDocument: textDocumentCapabilities,
window: windowCapabilities,
experimental: experimentalCapabilities,
),
);
path.Context get pathContext;
String get projectFolderPath;
ClientUriConverter get uriConverter;
Future<T> executeCommand<T>(
Command command, {
T Function(Map<String, Object?>)? decoder,
ProgressToken? workDoneToken,
});
/// 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),
);
}
/// Executes a function 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> executeForEdits(
Future<Object?> Function() function, {
ApplyWorkspaceEditResult? applyEditResult,
}) async {
ApplyWorkspaceEditParams? editParams;
var commandResponse =
await handleExpectedRequest<
Object?,
ApplyWorkspaceEditParams,
ApplyWorkspaceEditResult
>(
Method.workspace_applyEdit,
ApplyWorkspaceEditParams.fromJson,
function,
handler: (edit) async {
// When the server sends the edit back, just keep a copy and say we
// applied successfully (it'll be verified by the caller).
editParams = edit;
return applyEditResult ?? ApplyWorkspaceEditResult(applied: true);
},
);
// Successful edits return an empty success() response.
expect(commandResponse, isNull);
// Ensure the edit came back, and using the expected change type.
expect(editParams, isNotNull);
var edit = editParams!.edit;
var expectDocumentChanges = editorClientCapabilities.documentChanges;
expect(edit.documentChanges, expectDocumentChanges ? isNotNull : isNull);
expect(edit.changes, expectDocumentChanges ? isNull : isNotNull);
return LspChangeVerifier(this, edit);
}
/// A function to get the current contents of a file to apply edits.
String? getCurrentFileContent(Uri uri);
Future<T> handleExpectedRequest<T, R, RR>(
Method method,
R Function(Map<String, dynamic>) fromJson,
Future<T> Function() f, {
required Future<RR> Function(R) handler,
Duration timeout = const Duration(seconds: 5),
});
/// Formats a path relative to the project root always using forward slashes.
///
/// This is used in the text format for comparing edits.
String relativePath(String filePath) => pathContext
.relative(filePath, from: projectFolderPath)
.replaceAll(r'\', '/');
/// Formats a path relative to the project root always using forward slashes.
///
/// This is used in the text format for comparing edits.
String relativeUri(Uri uri) => relativePath(uriConverter.fromClientUri(uri));
/// 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;
}
}