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