| // Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file |
| // for details. All rights reserved. Use of this source code is governed by a |
| // BSD-style license that can be found in the LICENSE file. |
| |
| import 'dart:async'; |
| |
| import 'package:analysis_server/lsp_protocol/protocol.dart'; |
| import 'package:analysis_server/src/analysis_server.dart'; |
| import 'package:analysis_server/src/analytics/noop_analytics_manager.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/src/util/file_paths.dart' as file_paths; |
| import 'package:analyzer_plugin/protocol/protocol.dart' as plugin; |
| import 'package:analyzer_plugin/src/protocol/protocol_internal.dart' as plugin; |
| import 'package:collection/collection.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 '../mocks_lsp.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; |
| |
| abstract class AbstractLspAnalysisServerTest |
| with |
| ResourceProviderMixin, |
| ClientCapabilitiesHelperMixin, |
| LspAnalysisServerTestMixin, |
| ConfigurationFilesMixin { |
| late MockLspServerChannel channel; |
| late TestPluginManager pluginManager; |
| late LspAnalysisServer server; |
| late MockProcessRunner processRunner; |
| late MockHttpClient httpClient; |
| |
| /// The number of context builds that had already occurred the last time |
| /// resetContextBuildCounter() was called. |
| int _previousContextBuilds = 0; |
| |
| AnalysisServerOptions get serverOptions => AnalysisServerOptions(); |
| |
| @override |
| Stream<Message> get serverToClient => channel.serverToClient; |
| |
| DiscoveredPluginInfo configureTestPlugin({ |
| plugin.ResponseResult? respondWith, |
| plugin.Notification? notification, |
| plugin.ResponseResult? Function(plugin.RequestParams)? handler, |
| Duration respondAfter = Duration.zero, |
| }) { |
| final info = DiscoveredPluginInfo('a', 'b', 'c', server.notificationManager, |
| server.instrumentationService); |
| pluginManager.plugins.add(info); |
| |
| if (handler != null) { |
| pluginManager.handleRequest = (request) { |
| final response = handler(request); |
| return response == null |
| ? null |
| : <PluginInfo, Future<plugin.Response>>{ |
| info: Future.delayed(respondAfter) |
| .then((_) => response.toResponse('-', 1)) |
| }; |
| }; |
| } |
| |
| if (respondWith != null) { |
| pluginManager.broadcastResults = <PluginInfo, Future<plugin.Response>>{ |
| info: Future.delayed(respondAfter) |
| .then((_) => respondWith.toResponse('-', 1)) |
| }; |
| } |
| |
| if (notification != null) { |
| server.notificationManager |
| .handlePluginNotification(info.pluginId, notification); |
| } |
| |
| return info; |
| } |
| |
| void expectContextBuilds() => |
| expect(server.contextBuilds - _previousContextBuilds, greaterThan(0), |
| reason: 'Contexts should have been rebuilt'); |
| |
| void expectNoContextBuilds() => |
| expect(server.contextBuilds - _previousContextBuilds, equals(0), |
| reason: 'Contexts should not have been rebuilt'); |
| |
| /// Sends a request to the server and unwraps the result. Throws if the |
| /// response was not successful or returned an error. |
| @override |
| Future<T> expectSuccessfulResponseTo<T, R>( |
| RequestMessage request, T Function(R) fromJson) async { |
| final resp = await sendRequestToServer(request); |
| final error = resp.error; |
| if (error != null) { |
| throw error; |
| } else { |
| // resp.result should only be null when error != null if T allows null. |
| return resp.result == null ? null as T : fromJson(resp.result as R); |
| } |
| } |
| |
| /// Finds the registration for a given LSP method. |
| Registration? registrationFor( |
| List<Registration> registrations, |
| Method method, |
| ) { |
| return registrations.singleWhereOrNull((r) => r.method == method.toJson()); |
| } |
| |
| /// Finds a single registration for a given LSP method with Dart in its |
| /// documentSelector. |
| /// |
| /// Throws if there is not exactly one match. |
| Registration registrationForDart( |
| List<Registration> registrations, |
| Method method, |
| ) => |
| registrationsForDart(registrations, method).single; |
| |
| /// Finds the registrations for a given LSP method with Dart in their |
| /// documentSelector. |
| List<Registration> registrationsForDart( |
| List<Registration> registrations, |
| Method method, |
| ) { |
| bool includesDart(Registration r) { |
| final options = TextDocumentRegistrationOptions.fromJson( |
| r.registerOptions as Map<String, Object?>); |
| |
| return options.documentSelector?.any((selector) => |
| selector.language == dartLanguageId || |
| (selector.pattern?.contains('.dart') ?? false)) ?? |
| false; |
| } |
| |
| return registrations |
| .where((r) => r.method == method.toJson() && includesDart(r)) |
| .toList(); |
| } |
| |
| void resetContextBuildCounter() { |
| _previousContextBuilds = server.contextBuilds; |
| } |
| |
| @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(); |
| processRunner = MockProcessRunner(); |
| channel = MockLspServerChannel(debugPrintCommunication); |
| |
| // Create an SDK in the mock file system. |
| var sdkRoot = newFolder('/sdk'); |
| createMockSdk( |
| resourceProvider: resourceProvider, |
| root: sdkRoot, |
| ); |
| |
| pluginManager = TestPluginManager(); |
| server = LspAnalysisServer( |
| channel, |
| resourceProvider, |
| serverOptions, |
| DartSdkManager(sdkRoot.path), |
| NoopAnalyticsManager(), |
| CrashReportingAttachmentsBuilder.empty, |
| InstrumentationService.NULL_SERVICE, |
| httpClient: httpClient, |
| processRunner: processRunner); |
| server.pluginManager = pluginManager; |
| |
| projectFolderPath = convertPath('/home/my_project'); |
| 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, file_paths.pubspecYaml); |
| 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 = WorkspaceClientCapabilities(); |
| |
| final emptyWindowClientCapabilities = WindowClientCapabilities(); |
| |
| TextDocumentClientCapabilities extendTextDocumentCapabilities( |
| TextDocumentClientCapabilities source, |
| Map<String, dynamic> textDocumentCapabilities, |
| ) { |
| final json = source.toJson(); |
| mergeJson(textDocumentCapabilities, json); |
| return TextDocumentClientCapabilities.fromJson(json); |
| } |
| |
| WindowClientCapabilities extendWindowCapabilities( |
| WindowClientCapabilities source, |
| Map<String, dynamic> windowCapabilities, |
| ) { |
| final json = source.toJson(); |
| mergeJson(windowCapabilities, json); |
| return WindowClientCapabilities.fromJson(json); |
| } |
| |
| WorkspaceClientCapabilities extendWorkspaceCapabilities( |
| WorkspaceClientCapabilities source, |
| Map<String, dynamic> workspaceCapabilities, |
| ) { |
| final json = source.toJson(); |
| mergeJson(workspaceCapabilities, json); |
| return WorkspaceClientCapabilities.fromJson(json); |
| } |
| |
| void mergeJson(Map<String, dynamic> source, Map<String, dynamic> dest) { |
| for (var key in source.keys) { |
| var sourceValue = source[key]; |
| var destValue = dest[key]; |
| if (sourceValue is Map<String, dynamic> && |
| destValue is Map<String, dynamic>) { |
| mergeJson(sourceValue, destValue); |
| } else { |
| dest[key] = source[key]; |
| } |
| } |
| } |
| |
| 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}, |
| 'colorProvider': {'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}, |
| 'selectionRange': {'dynamicRegistration': true}, |
| 'semanticTokens': SemanticTokensClientCapabilities( |
| dynamicRegistration: true, |
| requests: SemanticTokensClientCapabilitiesRequests(), |
| formats: [], |
| tokenModifiers: [], |
| tokenTypes: []).toJson(), |
| 'typeDefinition': {'dynamicRegistration': true}, |
| }); |
| } |
| |
| WorkspaceClientCapabilities withAllSupportedWorkspaceDynamicRegistrations( |
| WorkspaceClientCapabilities 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}, |
| }); |
| } |
| |
| WorkspaceClientCapabilities withApplyEditSupport( |
| WorkspaceClientCapabilities 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()} |
| } |
| } |
| }); |
| } |
| |
| WorkspaceClientCapabilities withConfigurationSupport( |
| WorkspaceClientCapabilities source, |
| ) { |
| return extendWorkspaceCapabilities(source, {'configuration': true}); |
| } |
| |
| TextDocumentClientCapabilities withDiagnosticCodeDescriptionSupport( |
| TextDocumentClientCapabilities source, |
| ) { |
| return extendTextDocumentCapabilities(source, { |
| 'publishDiagnostics': { |
| 'codeDescriptionSupport': true, |
| } |
| }); |
| } |
| |
| TextDocumentClientCapabilities withDiagnosticTagSupport( |
| TextDocumentClientCapabilities source, |
| List<DiagnosticTag> tags, |
| ) { |
| return extendTextDocumentCapabilities(source, { |
| 'publishDiagnostics': { |
| 'tagSupport': {'valueSet': tags.map((k) => k.toJson()).toList()} |
| } |
| }); |
| } |
| |
| WorkspaceClientCapabilities withDidChangeConfigurationDynamicRegistration( |
| WorkspaceClientCapabilities source, |
| ) { |
| return extendWorkspaceCapabilities(source, { |
| 'didChangeConfiguration': {'dynamicRegistration': true} |
| }); |
| } |
| |
| WorkspaceClientCapabilities withDocumentChangesSupport( |
| WorkspaceClientCapabilities 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()} |
| } |
| }); |
| } |
| |
| WorkspaceClientCapabilities withFileOperationDynamicRegistration( |
| WorkspaceClientCapabilities 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}, |
| 'typeDefinition': {'linkSupport': true}, |
| 'implementation': {'linkSupport': true} |
| }); |
| } |
| |
| WorkspaceClientCapabilities withResourceOperationKinds( |
| WorkspaceClientCapabilities source, |
| List<ResourceOperationKind> kinds, |
| ) { |
| return extendWorkspaceCapabilities(source, { |
| 'workspaceEdit': { |
| 'documentChanges': |
| true, // docChanges aren't included in resourceOperations |
| 'resourceOperations': kinds.map((k) => k.toJson()).toList(), |
| } |
| }); |
| } |
| |
| 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} |
| }); |
| } |
| |
| WindowClientCapabilities withWorkDoneProgressSupport( |
| WindowClientCapabilities source) { |
| return extendWindowCapabilities(source, {'workDoneProgress': true}); |
| } |
| } |
| |
| mixin ConfigurationFilesMixin on ResourceProviderMixin { |
| String get latestLanguageVersion => |
| '${ExperimentStatus.currentVersion.major}.' |
| '${ExperimentStatus.currentVersion.minor}'; |
| |
| String get testPackageLanguageVersion => latestLanguageVersion; |
| |
| 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.parent.path); |
| } |
| |
| if (flutter) { |
| { |
| var libFolder = MockPackages.instance.addUI(resourceProvider); |
| config.add(name: 'ui', rootPath: libFolder.parent.path); |
| } |
| { |
| var libFolder = MockPackages.instance.addFlutter(resourceProvider); |
| config.add(name: 'flutter', rootPath: libFolder.parent.path); |
| } |
| } |
| |
| if (pedantic) { |
| var libFolder = MockPackages.instance.addPedantic(resourceProvider); |
| config.add(name: 'pedantic', rootPath: libFolder.parent.path); |
| } |
| |
| if (vector_math) { |
| var libFolder = MockPackages.instance.addVectorMath(resourceProvider); |
| config.add(name: 'vector_math', rootPath: libFolder.parent.path); |
| } |
| |
| var path = '$projectFolderPath/.dart_tool/package_config.json'; |
| var content = config.toContent(toUriStr: toUriStr); |
| newFile(path, 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 = ProgressToken.t2('client-test'); |
| |
| int _id = 0; |
| late String projectFolderPath, |
| mainFilePath, |
| pubspecFilePath, |
| analysisOptionsPath; |
| late 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 = <ProgressToken>{}; |
| |
| /// Whether to include 'clientRequestTime' fields in outgoing messages. |
| bool includeClientRequestTime = false; |
| |
| /// 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, |
| List<Either4<CreateFile, DeleteFile, RenameFile, TextDocumentEdit>> |
| 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); |
| } |
| applyResourceChanges(fileContents, documentChanges); |
| } |
| |
| void applyResourceChanges( |
| Map<String, String> oldFileContent, |
| List<Either4<CreateFile, DeleteFile, RenameFile, TextDocumentEdit>> changes, |
| ) { |
| for (final change in changes) { |
| change.map( |
| (create) => applyResourceCreate(oldFileContent, create), |
| (delete) => throw 'applyResourceChanges:Delete not currently supported', |
| (rename) => applyResourceRename(oldFileContent, rename), |
| (textDocEdit) => applyTextDocumentEdits(oldFileContent, [textDocEdit]), |
| ); |
| } |
| } |
| |
| void applyResourceCreate( |
| Map<String, String> oldFileContent, CreateFile create) { |
| final path = Uri.parse(create.uri).toFilePath(); |
| if (oldFileContent.containsKey(path)) { |
| throw 'Received create instruction for $path which already existed.'; |
| } |
| oldFileContent[path] = ''; |
| } |
| |
| void applyResourceRename( |
| Map<String, String> oldFileContent, RenameFile rename) { |
| final oldPath = Uri.parse(rename.oldUri).toFilePath(); |
| final newPath = Uri.parse(rename.newUri).toFilePath(); |
| if (!oldFileContent.containsKey(oldPath)) { |
| throw 'Received rename instruction for $oldPath which did not exist.'; |
| } |
| oldFileContent[newPath] = oldFileContent[oldPath]!; |
| oldFileContent.remove(oldPath); |
| } |
| |
| String applyTextDocumentEdit(String content, TextDocumentEdit edit) { |
| return edit.edits.fold(content, applyTextEdit); |
| } |
| |
| void applyTextDocumentEdits( |
| Map<String, String> oldFileContent, List<TextDocumentEdit> edits) { |
| for (var edit in edits) { |
| final path = Uri.parse(edit.textDocument.uri).toFilePath(); |
| if (!oldFileContent.containsKey(path)) { |
| throw 'Received 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<AnnotatedTextEdit, SnippetTextEdit, 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<AnnotatedTextEdit, SnippetTextEdit, TextEdit>.t3(change)); |
| } |
| |
| return newContent; |
| } |
| |
| Future changeFile( |
| int newVersion, |
| Uri uri, |
| List<TextDocumentContentChangeEvent> 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<T> executeCommand<T>( |
| Command command, { |
| T Function(Map<String, Object?>)? decoder, |
| ProgressToken? workDoneToken, |
| }) async { |
| final 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, Matcher 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]; |
| |
| expect(edit.textDocument.version, equals(expectedVersion)); |
| } |
| |
| /// Validates the document versions for a set of edits match the versions in |
| /// the supplied map. |
| void expectDocumentVersions( |
| List<Either4<CreateFile, DeleteFile, RenameFile, TextDocumentEdit>> |
| documentChanges, |
| Map<String, int> expectedVersions, |
| ) { |
| // For resource changes, we only need to validate changes since |
| // creates/renames/deletes do not supply versions. |
| for (var change in documentChanges) { |
| change.map( |
| (create) {}, |
| (delete) {}, |
| (rename) {}, |
| (edit) => expectDocumentVersion(edit, expectedVersions), |
| ); |
| } |
| } |
| |
| 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 as Map<String, Object?>); |
| } |
| |
| 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, |
| Position? position, |
| List<CodeActionKind>? kinds, |
| CodeActionTriggerKind? triggerKind, |
| }) { |
| range ??= position != null |
| ? Range(start: position, end: position) |
| : throw 'Supply either a Range or Position for CodeActions requests'; |
| final 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, |
| ), |
| ), |
| ); |
| return expectSuccessfulResponseTo( |
| request, |
| _fromJsonList(_generateFromJsonFor(Command.canParse, Command.fromJson, |
| CodeAction.canParse, CodeAction.fromJson)), |
| ); |
| } |
| |
| Future<List<ColorPresentation>> getColorPresentation( |
| String fileUri, Range range, Color color) { |
| final 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 { |
| final response = await getCompletionList(uri, pos, context: context); |
| return response.items; |
| } |
| |
| Future<CompletionList> getCompletionList(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, CompletionList.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<ColorInformation>> getDocumentColors(String fileUri) { |
| final request = makeRequest( |
| Method.textDocument_documentColor, |
| DocumentColorParams( |
| textDocument: TextDocumentIdentifier(uri: fileUri), |
| ), |
| ); |
| return expectSuccessfulResponseTo( |
| request, |
| _fromJsonList(ColorInformation.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(Uri uri) { |
| final request = makeRequest( |
| Method.textDocument_documentSymbol, |
| DocumentSymbolParams( |
| textDocument: TextDocumentIdentifier(uri: uri.toString()), |
| ), |
| ); |
| 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, { |
| bool 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<List<SelectionRange>?> getSelectionRanges( |
| Uri uri, List<Position> positions) { |
| final request = makeRequest( |
| Method.textDocument_selectionRange, |
| SelectionRangeParams( |
| textDocument: TextDocumentIdentifier(uri: uri.toString()), |
| positions: positions), |
| ); |
| return expectSuccessfulResponseTo( |
| request, _fromJsonList(SelectionRange.fromJson)); |
| } |
| |
| 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<TextDocumentTypeDefinitionResult> getTypeDefinition( |
| Uri uri, Position pos) { |
| final request = makeRequest( |
| Method.textDocument_typeDefinition, |
| TypeDefinitionParams( |
| textDocument: TextDocumentIdentifier(uri: uri.toString()), |
| 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> |
| final definitionCanParse = _generateCanParseFor( |
| _canParseList(Location.canParse), |
| Location.canParse, |
| ); |
| final 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 { |
| final 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 { |
| final results = (await getTypeDefinition(uri, pos))!; |
| return results.map( |
| (locationOrList) => throw 'Expected LocationLinks, got Locations', |
| (locationLinks) => locationLinks, |
| ); |
| } |
| |
| 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 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 FutureOr<RR> Function(R) handler, |
| Duration timeout = const Duration(seconds: 5), |
| }) async { |
| late 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 as Map<String, Object?>)); |
| 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, |
| WorkspaceClientCapabilities? workspaceCapabilities, |
| WindowClientCapabilities? windowCapabilities, |
| Map<String, Object?>? experimentalCapabilities, |
| Map<String, Object?>? initializationOptions, |
| bool throwOnFailure = true, |
| bool allowEmptyRootUri = false, |
| bool failTestOnAnyErrorNotification = true, |
| bool includeClientRequestTime = false, |
| }) async { |
| this.includeClientRequestTime = includeClientRequestTime; |
| |
| if (failTestOnAnyErrorNotification) { |
| errorNotificationsFromServer.listen((NotificationMessage error) { |
| fail('${error.toJson()}'); |
| }); |
| } |
| |
| final clientCapabilities = ClientCapabilities( |
| workspace: workspaceCapabilities, |
| textDocument: textDocumentCapabilities, |
| window: windowCapabilities, |
| experimental: experimentalCapabilities, |
| ); |
| _clientCapabilities = clientCapabilities; |
| |
| // Handle any standard incoming requests that aren't test-specific, for example |
| // accepting requests to create progress tokens. |
| requestsFromServer.listen((request) async { |
| if (request.method == Method.window_workDoneProgress_create) { |
| respondTo(request, await _handleWorkDoneProgressCreate(request)); |
| } |
| }); |
| |
| notificationsFromServer.listen((notification) async { |
| if (notification.method == Method.progress) { |
| await _handleProgress(notification); |
| } |
| }); |
| |
| // 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)); |
| |
| final error = response.error; |
| if (error == null) { |
| final notification = |
| makeNotification(Method.initialized, InitializedParams()); |
| await sendNotificationToServer(notification); |
| await pumpEventQueue(); |
| } else if (throwOnFailure) { |
| throw 'Error during initialize request: ' |
| '${error.code}: ${error.message}'; |
| } |
| |
| return response; |
| } |
| |
| NotificationMessage makeNotification(Method method, ToJsonable? params) { |
| return NotificationMessage( |
| method: method, |
| params: params, |
| jsonrpc: jsonRpcVersion, |
| clientRequestTime: includeClientRequestTime |
| ? DateTime.now().millisecondsSinceEpoch |
| : null, |
| ); |
| } |
| |
| RequestMessage makeRenameRequest( |
| int? version, Uri uri, Position pos, String newName) { |
| 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<int, String>.t1(_id++); |
| return RequestMessage( |
| id: id, |
| method: method, |
| params: params, |
| jsonrpc: jsonRpcVersion, |
| clientRequestTime: includeClientRequestTime |
| ? DateTime.now().millisecondsSinceEpoch |
| : null, |
| ); |
| } |
| |
| /// Watches for `client/registerCapability` requests and updates |
| /// `registrations`. |
| Future<T> monitorDynamicRegistrations<T>( |
| List<Registration> registrations, |
| Future<T> Function() f, |
| ) { |
| return handleExpectedRequest<T, RegistrationParams, void>( |
| Method.client_registerCapability, |
| RegistrationParams.fromJson, |
| f, |
| handler: (registrationParams) { |
| registrations.addAll(registrationParams.registrations); |
| }, |
| ); |
| } |
| |
| /// Expects both unregistration and reregistration. |
| Future<T> monitorDynamicReregistration<T>( |
| List<Registration> registrations, |
| Future<T> Function() f, |
| ) => |
| monitorDynamicUnregistrations( |
| registrations, |
| () => monitorDynamicRegistrations(registrations, f), |
| ); |
| |
| /// Watches for `client/unregisterCapability` requests and updates |
| /// `registrations`. |
| Future<T> monitorDynamicUnregistrations<T>( |
| List<Registration> registrations, |
| Future<T> Function() f, |
| ) { |
| return handleExpectedRequest<T, UnregistrationParams, void>( |
| Method.client_unregisterCapability, |
| UnregistrationParams.fromJson, |
| f, |
| handler: (unregistrationParams) { |
| registrations.removeWhere((element) => unregistrationParams |
| .unregisterations |
| .any((u) => u.id == element.id)); |
| }, |
| ); |
| } |
| |
| Future<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, {int 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(times: 128); |
| } |
| |
| int positionCompare(Position p1, Position p2) { |
| if (p1.line < p2.line) return -1; |
| if (p1.line > p2.line) return 1; |
| |
| if (p1.character < p2.character) return -1; |
| if (p1.character > p2.character) return 1; |
| |
| return 0; |
| } |
| |
| 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<PlaceholderAndRange?> prepareRename(Uri uri, Position pos) { |
| final request = makeRequest( |
| Method.textDocument_prepareRename, |
| TextDocumentPositionParams( |
| textDocument: TextDocumentIdentifier(uri: uri.toString()), |
| position: pos, |
| ), |
| ); |
| return expectSuccessfulResponseTo(request, PlaceholderAndRange.fromJson); |
| } |
| |
| /// Calls the supplied function and responds to any `workspace/configuration` |
| /// request with the supplied config. |
| Future<T> provideConfig<T>( |
| Future<T> Function() f, |
| FutureOr<Map<String, Object?>> globalConfig, { |
| FutureOr<Map<String, Map<String, Object?>>>? folderConfig, |
| }) { |
| return handleExpectedRequest<T, ConfigurationParams, |
| List<Map<String, Object?>>>( |
| Method.workspace_configuration, |
| ConfigurationParams.fromJson, |
| f, |
| handler: (configurationParams) async { |
| // We must respond to the request for config with items that match the |
| // request. For any item in the request without a folder, we will return |
| // the global config. For any item in the request with a folder we will |
| // return the config for that item in the map, or fall back to the global |
| // config if it does not exist. |
| final global = await globalConfig; |
| final folders = await folderConfig; |
| return configurationParams.items.map( |
| (requestedConfig) { |
| final uri = requestedConfig.scopeUri; |
| final path = uri != null ? Uri.parse(uri).toFilePath() : null; |
| // Use the config the test provided for this path, or fall back to |
| // global. |
| return (folders != null ? folders[path] : null) ?? global; |
| }, |
| ).toList(); |
| }, |
| ); |
| } |
| |
| /// Returns the range 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, |
| [ |
| TextDocumentContentChangeEvent.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 as Null); |
| } |
| |
| /// Creates a [TextEdit] using the `insert` range of a [InsertReplaceEdit]. |
| TextEdit textEditForInsert(Either2<InsertReplaceEdit, TextEdit> edit) => |
| edit.map( |
| (e) => TextEdit(range: e.insert, newText: e.newText), |
| (_) => throw 'Expected InsertReplaceEdit, got TextEdit', |
| ); |
| |
| /// Creates a [TextEdit] using the `replace` range of a [InsertReplaceEdit]. |
| TextEdit textEditForReplace(Either2<InsertReplaceEdit, TextEdit> edit) => |
| edit.map( |
| (e) => TextEdit(range: e.replace, newText: e.newText), |
| (_) => throw 'Expected InsertReplaceEdit, got TextEdit', |
| ); |
| |
| TextEdit toTextEdit(Either2<InsertReplaceEdit, TextEdit> edit) => edit.map( |
| (_) => throw 'Expected TextEdit, got InsertReplaceEdit', |
| (e) => e, |
| ); |
| |
| WorkspaceFolder toWorkspaceFolder(Uri uri) { |
| return WorkspaceFolder( |
| uri: uri.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( |
| 'Received ${CustomMethods.analyzerStatus} notification ' |
| 'but client supports workDoneProgress'); |
| } |
| |
| final params = AnalyzerStatusParams.fromJson( |
| message.params as Map<String, Object?>); |
| return params.isAnalyzing == analyzing; |
| } else if (message.method == Method.progress) { |
| if (_clientCapabilities!.window?.workDoneProgress != true) { |
| throw Exception( |
| 'Received ${CustomMethods.analyzerStatus} notification ' |
| 'but client supports workDoneProgress'); |
| } |
| |
| final params = |
| ProgressParams.fromJson(message.params as Map<String, Object?>); |
| |
| // Skip unrelated progress notifications. |
| if (params.token != analyzingProgressToken) { |
| return false; |
| } |
| |
| if (params.value is Map<String, dynamic>) { |
| 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 { |
| late PublishClosingLabelsParams closingLabelsParams; |
| await serverToClient.firstWhere((message) { |
| if (message is NotificationMessage && |
| message.method == CustomMethods.publishClosingLabels) { |
| closingLabelsParams = PublishClosingLabelsParams.fromJson( |
| message.params as Map<String, Object?>); |
| |
| return closingLabelsParams.uri == uri.toString(); |
| } |
| return false; |
| }); |
| return closingLabelsParams.labels; |
| } |
| |
| Future<List<Diagnostic>?> waitForDiagnostics(Uri uri) async { |
| PublishDiagnosticsParams? diagnosticParams; |
| await serverToClient.map<Message?>((message) => message).firstWhere( |
| (message) { |
| if (message is NotificationMessage && |
| message.method == Method.textDocument_publishDiagnostics) { |
| diagnosticParams = PublishDiagnosticsParams.fromJson( |
| message.params as Map<String, Object?>); |
| return diagnosticParams!.uri == uri.toString(); |
| } |
| return false; |
| }, orElse: () => null); |
| return diagnosticParams?.diagnostics; |
| } |
| |
| Future<FlutterOutline> waitForFlutterOutline(Uri uri) async { |
| late PublishFlutterOutlineParams outlineParams; |
| await serverToClient.firstWhere((message) { |
| if (message is NotificationMessage && |
| message.method == CustomMethods.publishFlutterOutline) { |
| outlineParams = PublishFlutterOutlineParams.fromJson( |
| message.params as Map<String, Object?>); |
| |
| return outlineParams.uri == uri.toString(); |
| } |
| return false; |
| }); |
| return outlineParams.outline; |
| } |
| |
| Future<Outline> waitForOutline(Uri uri) async { |
| late PublishOutlineParams outlineParams; |
| await serverToClient.firstWhere((message) { |
| if (message is NotificationMessage && |
| message.method == CustomMethods.publishOutline) { |
| outlineParams = PublishOutlineParams.fromJson( |
| message.params as Map<String, Object?>); |
| |
| 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(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(); |
| |
| Future<void> _handleProgress(NotificationMessage request) async { |
| final params = |
| ProgressParams.fromJson(request.params as Map<String, Object?>); |
| if (params.token != clientProvidedTestWorkDoneToken && |
| !validProgressTokens.contains(params.token)) { |
| throw Exception('Server sent a progress notification for a token ' |
| 'that has not been created: ${params.token}'); |
| } |
| |
| if (WorkDoneProgressEnd.canParse(params.value, nullLspJsonReporter)) { |
| validProgressTokens.remove(params.token); |
| } |
| } |
| |
| 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 as Map<String, Object?>); |
| if (validProgressTokens.contains(params.token)) { |
| throw Exception('Server tried to create already-active progress token'); |
| } |
| validProgressTokens.add(params.token); |
| } |
| |
| /// Checks whether a notification is likely an error from the server (for |
| /// example a window/showMessage). This is useful for tests that want to |
| /// ensure no errors come from the server in response to notifications (which |
| /// don't have their own responses). |
| bool _isErrorNotification(NotificationMessage notification) { |
| return notification.method == Method.window_logMessage || |
| notification.method == Method.window_showMessage; |
| } |
| |
| /// 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)'; |
| }; |
| } |
| } |