Files
sdk/pkg/analysis_server/test/lsp/initialization_test.dart
T
Danny Tuppeny 1ea71b4b9b [analysis_server] Handle unexpected types in LSP experimental client capabilities LSP gracefully
Previously if you sent values in experimental capabilities that are different types to what we'd expect, in some cases we'd throw an exception and fail to initialize the server.

Since the experimental section is not specified (it's specifically for off-spec things) the behaviour of sending values should (IMO) be considered server-specific. However, reporting a useful message and continuing to function is clearly better than reporting a cryptic error and failing to initialize.

At least one client (`kak-lsp`) appears to be sending values in `"commands"` intended for the Rust LSP server which uses a different format to we're using (a nested Map where we use a List). This change will prevent the failure for them and instead report a warning. My recommendation is that their client is also updated to not send Rust experimental capabilities to non-Rust servers.

Fixes https://github.com/dart-lang/sdk/issues/55935

Change-Id: I8cba1aa3a5beebf884ff247180f809ffd33b3111
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/371400
Reviewed-by: Brian Wilkerson <brianwilkerson@google.com>
Reviewed-by: Sam Rawlins <srawlins@google.com>
Commit-Queue: Brian Wilkerson <brianwilkerson@google.com>
2024-06-13 17:29:49 +00:00

1233 lines
48 KiB
Dart

// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:async';
import 'package:analysis_server/lsp_protocol/protocol.dart';
import 'package:analysis_server/src/analysis_server.dart' hide MessageType;
import 'package:analysis_server/src/lsp/constants.dart';
import 'package:analysis_server/src/lsp/server_capabilities_computer.dart';
import 'package:analysis_server/src/plugin/plugin_manager.dart';
import 'package:analyzer/file_system/memory_file_system.dart';
import 'package:analyzer/src/util/file_paths.dart' as file_paths;
import 'package:language_server_protocol/json_parsing.dart';
import 'package:test/test.dart';
import 'package:test_reflective_loader/test_reflective_loader.dart';
import 'change_workspace_folders_test.dart';
import 'server_abstract.dart';
void main() {
defineReflectiveSuite(() {
defineReflectiveTests(InitializationTest);
defineReflectiveTests(SlowInitializationTest);
});
}
@reflectiveTest
class InitializationTest extends AbstractLspAnalysisServerTest {
/// Waits for any in-progress analysis context rebuild.
///
/// Pumps the event queue before and after, to ensure any server code that
/// runs after the rebuild has had chance to run.
Future<void> get contextRebuildComplete async {
await pumpEventQueue(times: 5000);
await server.analysisContextsRebuilt;
await pumpEventQueue(times: 5000);
}
Future<void> assertDynamicRegistration(
String name, Set<Method> expectedResult) async {
setTextDocumentDynamicRegistration(name);
setWorkspaceDynamicRegistration(name);
// Check that when the server calls client/registerCapability it only includes
// the items we advertised dynamic registration support for.
var registrations = <Registration>[];
await monitorDynamicRegistrations(
registrations,
initialize,
);
var registeredMethods =
registrations.map((registration) => registration.method).toSet();
var result = expectedResult.map((method) => method.toJson()).toSet();
expect(registeredMethods, equals(result));
}
/// Expects successful initialization, but a window/showMessage warning
/// [expectedMessage] for the given [experimentalCapabilities].
Future<void> expectInvalidExperimentalParams(
Map<String, Object?> experimentalCapabilities,
String expectedMessage,
) async {
var warning = showMessageNotifications
.firstWhere((m) => m.type == MessageType.Warning);
await initialize(experimentalCapabilities: experimentalCapabilities);
var message = (await warning).message;
expect(
message,
allOf(
contains('errors parsing your experimental client capabilities'),
contains(expectedMessage),
),
);
}
TextDocumentRegistrationOptions registrationOptionsFor(
List<Registration> registrations,
Method method,
) {
var options = registrationFor(registrations, method)?.registerOptions;
if (options == null) {
throw 'Registration options for $method were not found. '
'Perhaps dynamicRegistration is missing from '
'withAllSupportedTextDocumentDynamicRegistrations?';
}
return TextDocumentRegistrationOptions.fromJson(
options as Map<String, Object?>);
}
Future<void> test_blazeWorkspace() async {
var workspacePath = '/home/user/ws';
// Make it a Blaze workspace.
newFile('$workspacePath/${file_paths.blazeWorkspaceMarker}', '');
var packagePath = '$workspacePath/team/project1';
// Make it a Blaze project.
newFile(convertPath('$packagePath/BUILD'), '');
var file1 = convertPath('$packagePath/lib/file1.dart');
newFile(file1, '');
await initialize(allowEmptyRootUri: true);
// Expect that context manager includes a whole package.
await openFile(pathContext.toUri(file1), '');
expect(server.contextManager.includedPaths,
equals([convertPath(packagePath)]));
}
Future<void> test_completionRegistrations_triggerCharacters() async {
// Support dynamic registration for everything we support.
setAllSupportedTextDocumentDynamicRegistrations();
setAllSupportedWorkspaceDynamicRegistrations();
var registrations = <Registration>[];
var initResponse = await monitorDynamicRegistrations(
registrations,
() => initialize(),
);
var initResult =
InitializeResult.fromJson(initResponse.result as Map<String, Object?>);
expect(initResult.capabilities, isNotNull);
// Check Dart-only registration.
var dartRegistration =
registrationForDart(registrations, Method.textDocument_completion);
var dartOptions = CompletionRegistrationOptions.fromJson(
dartRegistration.registerOptions as Map<String, Object?>);
expect(dartOptions.documentSelector, hasLength(1));
expect(dartOptions.documentSelector![0].language, dartLanguageId);
expect(dartOptions.triggerCharacters, isNotEmpty);
// Check non-Dart registration.
var nonDartRegistration = registrations.singleWhere((r) =>
r.method == Method.textDocument_completion.toJson() &&
r != dartRegistration);
var nonDartOptions = CompletionRegistrationOptions.fromJson(
nonDartRegistration.registerOptions as Map<String, Object?>);
var otherLanguages = nonDartOptions.documentSelector!
.map((selector) => selector.language)
.toList();
expect(otherLanguages, isNot(contains('dart')));
expect(nonDartOptions.triggerCharacters, isNull);
}
Future<void> test_completionRegistrations_withDartPlugin() async {
if (!AnalysisServer.supportsPlugins) return;
// This tests for a bug that occurred with an analysis server plugin
// that works on Dart files. When computing completion registrations we
// usually have separate registrations for Dart + non-Dart to account for
// different trigger characters. However, the plugin types were all being
// included in the non-Dart registration even if they included Dart.
//
// The result was two registrations including Dart, which caused duplicate
// requests for Dart completions, which resulted in duplicate items
// appearing in the editor.
setAllSupportedTextDocumentDynamicRegistrations();
// Track all current registrations.
var registrations = <Registration>[];
// Perform normal registration (without plugins) to get the initial set.
await monitorDynamicRegistrations(registrations, initialize);
// Expect only a single registration that includes Dart files.
expect(
registrationsForDart(registrations, Method.textDocument_completion),
hasLength(1),
);
// Monitor the unregistration/new registrations during the plugin activation.
await monitorDynamicReregistration(registrations, () async {
var plugin = configureTestPlugin();
plugin.currentSession = PluginSession(plugin)
..interestingFiles = ['*.dart'];
pluginManager.pluginsChangedController.add(null);
});
// Expect that there is still only a single registration for Dart.
expect(
registrationsForDart(registrations, Method.textDocument_completion),
hasLength(1),
);
}
Future<void> test_dynamicRegistration_areNotInterleaved() async {
if (!AnalysisServer.supportsPlugins) return;
// Some of the issues in https://github.com/dart-lang/sdk/issues/47851
// (duplicate hovers/code actions/etc.) were caused by duplicate
// registrations. This happened when we tried to rebuild registrations
// concurrently as the code would:
//
// 1. compute the new set of registrations
// 2. compute which had changed
// 3. send _and await_ the unregistration
// 4. send _and await_ the new registration
//
// If the code was triggered multiple times concurrently, it was possible
// for step 3 in second request to occur before step 4 in the first, which
// would result in unregistrations being sent for registrations that had
// not been sent. They would be silently dropped, and then we'd end up with
// multiple registrations.
//
// This test triggers the rebuild via pluginManager.pluginsChangedController
// multiple times and ensures the events all arrive in the correct order,
// that is, no unregistration ever contains the ID of something that is not
// currently registered.
setAllSupportedTextDocumentDynamicRegistrations();
var registrations = <Registration>[];
await monitorDynamicRegistrations(registrations, initialize);
var knownRegistrationsIds =
registrations.map((registration) => registration.id).toSet();
var numberOfReregistrations = 10;
// Listen to incoming registrations, ensure they're not in the set, and add
// them.
var registrationsDone = requestsFromServer
.where((n) => n.method == Method.client_registerCapability)
.take(numberOfReregistrations)
.listen((request) {
respondTo(request, null);
var registrations =
RegistrationParams.fromJson(request.params as Map<String, Object?>)
.registrations;
for (var registration in registrations) {
var id = registration.id;
if (!knownRegistrationsIds.add(id)) {
throw 'Registration $id was already in the existing set!';
}
}
}).asFuture();
// Listen to incoming unregistrations, verify they're in the set, and remove
// them.
var unregistrationsDone = requestsFromServer
.where((n) => n.method == Method.client_unregisterCapability)
.take(numberOfReregistrations)
.listen((request) {
respondTo(request, null);
var unregistrations =
UnregistrationParams.fromJson(request.params as Map<String, Object?>)
.unregisterations;
for (var unregistration in unregistrations) {
var id = unregistration.id;
if (!knownRegistrationsIds.remove(id)) {
throw 'Registration $id was not in the existing set!';
}
}
}).asFuture();
// Trigger multiple plugin events that will rebuild the registrations.
for (var i = 0; i < numberOfReregistrations; i++) {
var plugin = configureTestPlugin();
plugin.currentSession = PluginSession(plugin);
// Ensure they have different file types so the registrations change,
// otherwise they will be optimised out as not changing.
plugin.currentSession!.interestingFiles = ['*.foo$i'];
pluginManager.pluginsChangedController.add(null);
await null; // Allow the server to begin processing the change.
}
// Wait for both streams to have handled all of the numberOfReregistrations
// expected events (without throwing).
await Future.wait([registrationsDone, unregistrationsDone]);
}
Future<void> test_dynamicRegistration_config_allHierarchy() =>
assertDynamicRegistration(
'callHierarchy', {Method.textDocument_prepareCallHierarchy});
Future<void> test_dynamicRegistration_config_codeAction() =>
assertDynamicRegistration('codeAction', {Method.textDocument_codeAction});
Future<void> test_dynamicRegistration_config_codeLens() =>
assertDynamicRegistration('codeLens', {Method.textDocument_codeLens});
Future<void> test_dynamicRegistration_config_colorProvider() =>
assertDynamicRegistration(
'colorProvider', {Method.textDocument_documentColor});
Future<void> test_dynamicRegistration_config_completion() =>
assertDynamicRegistration('completion', {Method.textDocument_completion});
Future<void> test_dynamicRegistration_config_definition() =>
assertDynamicRegistration('definition', {Method.textDocument_definition});
Future<void> test_dynamicRegistration_config_didChangeConfiguration() =>
assertDynamicRegistration(
'didChangeConfiguration', {Method.workspace_didChangeConfiguration});
Future<void> test_dynamicRegistration_config_documentHighlight() =>
assertDynamicRegistration(
'documentHighlight', {Method.textDocument_documentHighlight});
Future<void> test_dynamicRegistration_config_documentLink() =>
assertDynamicRegistration(
'documentLink', {Method.textDocument_documentLink});
Future<void> test_dynamicRegistration_config_documentSymbol() =>
assertDynamicRegistration(
'documentSymbol', {Method.textDocument_documentSymbol});
Future<void> test_dynamicRegistration_config_fileOperations() =>
assertDynamicRegistration(
'fileOperations', {Method.workspace_willRenameFiles});
Future<void> test_dynamicRegistration_config_foldingRange() =>
assertDynamicRegistration(
'foldingRange', {Method.textDocument_foldingRange});
Future<void> test_dynamicRegistration_config_formatting() =>
assertDynamicRegistration('formatting', {Method.textDocument_formatting});
Future<void> test_dynamicRegistration_config_hover() =>
assertDynamicRegistration('hover', {Method.textDocument_hover});
Future<void> test_dynamicRegistration_config_implementation() =>
assertDynamicRegistration(
'implementation', {Method.textDocument_implementation});
Future<void> test_dynamicRegistration_config_inlayHint() =>
assertDynamicRegistration('inlayHint', {Method.textDocument_inlayHint});
Future<void> test_dynamicRegistration_config_onTypeFormatting() =>
assertDynamicRegistration(
'onTypeFormatting', {Method.textDocument_onTypeFormatting});
Future<void> test_dynamicRegistration_config_rangeFormatting() =>
assertDynamicRegistration(
'rangeFormatting', {Method.textDocument_rangeFormatting});
Future<void> test_dynamicRegistration_config_references() =>
assertDynamicRegistration('references', {Method.textDocument_references});
Future<void> test_dynamicRegistration_config_rename() =>
assertDynamicRegistration('rename', {Method.textDocument_rename});
Future<void> test_dynamicRegistration_config_selectionRange() =>
assertDynamicRegistration(
'selectionRange', {Method.textDocument_selectionRange});
Future<void> test_dynamicRegistration_config_semanticTokens() =>
assertDynamicRegistration(
'semanticTokens', {CustomMethods.semanticTokenDynamicRegistration});
Future<void> test_dynamicRegistration_config_signatureHelp() =>
assertDynamicRegistration(
'signatureHelp', {Method.textDocument_signatureHelp});
Future<void> test_dynamicRegistration_config_synchronization() =>
assertDynamicRegistration('synchronization', {
Method.textDocument_didOpen,
Method.textDocument_didChange,
Method.textDocument_didClose
});
Future<void> test_dynamicRegistration_config_typeDefinition() =>
assertDynamicRegistration(
'typeDefinition', {Method.textDocument_typeDefinition});
Future<void> test_dynamicRegistration_config_typeHierarchy() =>
assertDynamicRegistration(
'typeHierarchy', {Method.textDocument_prepareTypeHierarchy});
Future<void> test_dynamicRegistration_containsAppropriateSettings() async {
// Support file operations.
setFileOperationDynamicRegistration();
// Support dynamic registration for both text sync + hovers.
setTextSyncDynamicRegistration();
setHoverDynamicRegistration();
// Basic check that the server responds with the capabilities we'd expect,
// for ex including analysis_options.yaml in text synchronization but not
// for hovers.
var registrations = <Registration>[];
var initResponse = await monitorDynamicRegistrations(
registrations,
initialize,
);
// Because we support dynamic registration for synchronization, we won't send
// static registrations for them.
// https://github.com/dart-lang/sdk/issues/38490
var initResult =
InitializeResult.fromJson(initResponse.result as Map<String, Object?>);
expect(initResult.serverInfo!.name, 'Dart SDK LSP Analysis Server');
expect(initResult.serverInfo!.version, isNotNull);
expect(initResult.capabilities, isNotNull);
expect(initResult.capabilities.textDocumentSync, isNull);
// Should contain Hover, DidOpen, DidClose, DidChange, WillRenameFiles.
expect(registrations, hasLength(5));
var hover =
registrationOptionsFor(registrations, Method.textDocument_hover);
var change =
registrationOptionsFor(registrations, Method.textDocument_didChange);
var rename = FileOperationRegistrationOptions.fromJson(
registrationFor(registrations, Method.workspace_willRenameFiles)
?.registerOptions as Map<String, Object?>);
expect(registrationOptionsFor(registrations, Method.textDocument_didOpen),
isNotNull);
expect(registrationOptionsFor(registrations, Method.textDocument_didClose),
isNotNull);
// The hover capability should only specific Dart.
expect(hover, isNotNull);
expect(hover.documentSelector, hasLength(1));
expect(hover.documentSelector!.single.language, equals('dart'));
// didChange should also include pubspec + analysis_options.
expect(change, isNotNull);
expect(change.documentSelector, hasLength(greaterThanOrEqualTo(3)));
expect(change.documentSelector!.any((ds) => ds.language == 'dart'), isTrue);
expect(
change.documentSelector!.any((ds) => ds.pattern == '**/pubspec.yaml'),
isTrue);
expect(
change.documentSelector!
.any((ds) => ds.pattern == '**/analysis_options.yaml'),
isTrue);
expect(rename, equals(fileOperationRegistrationOptions));
}
Future<void> test_dynamicRegistration_notSupportedByClient() async {
// If the client doesn't send any dynamicRegistration settings then there
// should be no `client/registerCapability` calls.
// Set a flag if any registerCapability request comes through.
var didGetRegisterCapabilityRequest = false;
requestsFromServer
.where((n) => n.method == Method.client_registerCapability)
.listen((_) => didGetRegisterCapabilityRequest = true);
// Initialize with no dynamic registrations advertised.
var initResponse = await initialize();
await pumpEventQueue();
var initResult =
InitializeResult.fromJson(initResponse.result as Map<String, Object?>);
expect(initResult.capabilities, isNotNull);
// When dynamic registration is not supported, we will always statically
// request text document open/close and incremental updates.
expect(initResult.capabilities.textDocumentSync, isNotNull);
initResult.capabilities.textDocumentSync!.map(
(_) =>
throw 'Expected textDocumentSync capabilities to be a TextDocumentSyncOptions',
(options) {
expect(options.openClose, isTrue);
expect(options.change, equals(TextDocumentSyncKind.Incremental));
},
);
expect(initResult.capabilities.callHierarchyProvider, isNotNull);
expect(initResult.capabilities.completionProvider, isNotNull);
expect(initResult.capabilities.hoverProvider, isNotNull);
expect(initResult.capabilities.signatureHelpProvider, isNotNull);
expect(initResult.capabilities.referencesProvider, isNotNull);
expect(initResult.capabilities.colorProvider, isNotNull);
expect(initResult.capabilities.documentHighlightProvider, isNotNull);
expect(initResult.capabilities.documentFormattingProvider, isNotNull);
expect(initResult.capabilities.documentOnTypeFormattingProvider, isNotNull);
expect(initResult.capabilities.documentRangeFormattingProvider, isNotNull);
expect(initResult.capabilities.definitionProvider, isNotNull);
expect(initResult.capabilities.codeActionProvider, isNotNull);
expect(initResult.capabilities.renameProvider, isNotNull);
expect(initResult.capabilities.foldingRangeProvider, isNotNull);
expect(initResult.capabilities.workspace!.fileOperations!.willRename,
equals(fileOperationRegistrationOptions));
expect(initResult.capabilities.selectionRangeProvider, isNotNull);
expect(initResult.capabilities.semanticTokensProvider, isNotNull);
expect(didGetRegisterCapabilityRequest, isFalse);
}
Future<void> test_dynamicRegistration_suppressesStaticRegistration() async {
// Support dynamic registration for everything we support.
setAllSupportedTextDocumentDynamicRegistrations();
setAllSupportedWorkspaceDynamicRegistrations();
// If the client sends dynamicRegistration settings then there
// should not be static registrations for the same capabilities.
var registrations = <Registration>[];
var initResponse = await monitorDynamicRegistrations(
registrations,
initialize,
);
var initResult =
InitializeResult.fromJson(initResponse.result as Map<String, Object?>);
expect(initResult.capabilities, isNotNull);
// Ensure no static registrations. This list should include all server equivalents
// of the dynamic registrations listed in `ClientDynamicRegistrations.supported`.
expect(initResult.capabilities.textDocumentSync, isNull);
expect(initResult.capabilities.callHierarchyProvider, isNull);
expect(initResult.capabilities.completionProvider, isNull);
expect(initResult.capabilities.hoverProvider, isNull);
expect(initResult.capabilities.inlayHintProvider, isNull);
expect(initResult.capabilities.signatureHelpProvider, isNull);
expect(initResult.capabilities.referencesProvider, isNull);
expect(initResult.capabilities.colorProvider, isNull);
expect(initResult.capabilities.documentHighlightProvider, isNull);
expect(initResult.capabilities.documentFormattingProvider, isNull);
expect(initResult.capabilities.documentOnTypeFormattingProvider, isNull);
expect(initResult.capabilities.documentRangeFormattingProvider, isNull);
expect(initResult.capabilities.definitionProvider, isNull);
expect(initResult.capabilities.codeActionProvider, isNull);
expect(initResult.capabilities.renameProvider, isNull);
expect(initResult.capabilities.foldingRangeProvider, isNull);
expect(initResult.capabilities.workspace!.fileOperations, isNull);
expect(initResult.capabilities.selectionRangeProvider, isNull);
expect(initResult.capabilities.semanticTokensProvider, isNull);
// Ensure all expected dynamic registrations.
for (var expectedRegistration in ClientDynamicRegistrations.supported) {
// We have two completion registrations (to handle different trigger
// characters), so exclude that here and check it manually below.
if (expectedRegistration == Method.textDocument_completion) {
continue;
}
var registration =
registrationOptionsFor(registrations, expectedRegistration);
expect(registration, isNotNull,
reason: 'Missing dynamic registration for $expectedRegistration');
}
// Check the were two completion registrations.
var completionRegistrations = registrations
.where((reg) => reg.method == Method.textDocument_completion.toJson());
expect(completionRegistrations, hasLength(2));
}
Future<void> test_dynamicRegistration_unregistersOutdatedAfterChange() async {
if (!AnalysisServer.supportsPlugins) return;
// Initialize by supporting dynamic registrations everywhere
setAllSupportedTextDocumentDynamicRegistrations();
var registrations = <Registration>[];
await monitorDynamicRegistrations(
registrations,
initialize,
);
var unregisterRequest =
await expectRequest(Method.client_unregisterCapability, () {
var plugin = configureTestPlugin();
plugin.currentSession = PluginSession(plugin)
..interestingFiles = ['*.foo'];
pluginManager.pluginsChangedController.add(null);
});
var unregistrations = UnregistrationParams.fromJson(
unregisterRequest.params as Map<String, Object?>)
.unregisterations;
// folding method should have been unregistered as the server now supports
// *.foo files for it as well.
var registrationIdForFolding = registrations
.singleWhere((r) => r.method == 'textDocument/foldingRange')
.id;
expect(
unregistrations,
contains(isA<Unregistration>()
.having((r) => r.method, 'method', 'textDocument/foldingRange')
.having((r) => r.id, 'id', registrationIdForFolding)),
);
}
Future<void> test_dynamicRegistration_updatesWithPlugins() async {
if (!AnalysisServer.supportsPlugins) return;
setTextDocumentDynamicRegistration('foldingRange');
await initialize();
// The server will send an unregister request followed by another register
// request to change document filter on folding. We need to respond to the
// unregister request as the server awaits that.
// This is set up as a future callback and should not be awaited here.
unawaited(requestsFromServer
.firstWhere((r) => r.method == Method.client_unregisterCapability)
.then((request) {
respondTo(request, null);
}));
var request = await expectRequest(Method.client_registerCapability, () {
var plugin = configureTestPlugin();
plugin.currentSession = PluginSession(plugin)
..interestingFiles = ['*.sql'];
pluginManager.pluginsChangedController.add(null);
});
var registrations =
RegistrationParams.fromJson(request.params as Map<String, Object?>)
.registrations;
var documentFilterSql =
TextDocumentFilterWithScheme(scheme: 'file', pattern: '**/*.sql');
var documentFilterDart =
TextDocumentFilterWithScheme(language: 'dart', scheme: 'file');
expect(
registrations,
contains(isA<Registration>()
.having((r) => r.method, 'method', 'textDocument/foldingRange')
.having(
(r) => TextDocumentRegistrationOptions.fromJson(
r.registerOptions as Map<String, Object?>)
.documentSelector,
'registerOptions.documentSelector',
containsAll([documentFilterSql, documentFilterDart]),
)),
);
}
/// Tests that when there are no explicit analysis roots (instead they are
/// implied by open files), requests for open files are successful even if
/// sent _immediately_ after opening the file.
///
/// https://github.com/Dart-Code/Dart-Code/issues/3929
///
/// This test uses getDocumentSymbols which requires a resolved result.
Future<void>
test_emptyAnalysisRoots_handlesFileRequestsImmediately_resolved() async {
const content = 'void f() {}';
var file1 = join(projectFolderPath, 'file1.dart');
var file1Uri = pathContext.toUri(file1);
newFile(file1, content);
newPubspecYamlFile(projectFolderPath, '');
await initialize(allowEmptyRootUri: true);
unawaited(openFile(file1Uri, content)); // Don't wait
var result = await getDocumentSymbols(file1Uri);
var symbols = result.map(
(docSymbols) => docSymbols,
(symbolInfos) => symbolInfos,
);
expect(symbols, hasLength(1));
}
/// Tests that when there are no explicit analysis roots (instead they are
/// implied by open files), requests for open files are successful even if
/// sent _immediately_ after opening the file.
///
/// https://github.com/Dart-Code/Dart-Code/issues/3929
///
/// This test uses getSelectionRanges which requires only a parsed result.
Future<void>
test_emptyAnalysisRoots_handlesFileRequestsImmediately_unresolved() async {
const content = 'void f() {}';
var file1 = join(projectFolderPath, 'file1.dart');
var file1Uri = pathContext.toUri(file1);
newFile(file1, content);
newPubspecYamlFile(projectFolderPath, '');
await initialize(allowEmptyRootUri: true);
unawaited(openFile(file1Uri, content)); // Don't wait
var result = await getSelectionRanges(file1Uri, [startOfDocPos]);
expect(result, hasLength(1));
}
Future<void> test_emptyAnalysisRoots_multipleOpenFiles() async {
var file1 = join(projectFolderPath, 'file1.dart');
var file1Uri = pathContext.toUri(file1);
newFile(file1, '');
var file2 = join(projectFolderPath, 'file2.dart');
var file2Uri = pathContext.toUri(file2);
newFile(file2, '');
newPubspecYamlFile(projectFolderPath, '');
await initialize(allowEmptyRootUri: true);
// Opening both files should only add the project folder once.
await openFile(file1Uri, '');
await openFile(file2Uri, '');
await contextRebuildComplete;
expect(server.contextManager.includedPaths, equals([projectFolderPath]));
// Closing only one of the files should not remove the project folder
// since there are still open files.
resetContextBuildCounter();
await closeFile(file1Uri);
await pumpEventQueue(times: 5000);
expect(server.contextManager.includedPaths, equals([projectFolderPath]));
expectNoContextBuilds();
// Closing the last file should remove the project folder and remove
// the context.
resetContextBuildCounter();
await closeFile(file2Uri);
await contextRebuildComplete;
expect(server.contextManager.includedPaths, equals([]));
expect(server.contextManager.driverMap, hasLength(0));
expectContextBuilds();
}
Future<void> test_emptyAnalysisRoots_projectWithoutPubspec() async {
projectFolderPath = convertPath('/home/empty');
var nestedFilePath = join(
projectFolderPath, 'nested', 'deeply', 'in', 'folders', 'test.dart');
var nestedFileUri = pathContext.toUri(nestedFilePath);
newFile(nestedFilePath, '');
// The project folder shouldn't be added to start with.
await initialize(allowEmptyRootUri: true);
expect(server.contextManager.includedPaths, equals([]));
// Opening the file will add a root for it.
await openFile(nestedFileUri, '');
expect(server.contextManager.includedPaths, equals([nestedFilePath]));
}
Future<void> test_emptyAnalysisRoots_projectWithPubspec() async {
var nestedFilePath = join(
projectFolderPath, 'nested', 'deeply', 'in', 'folders', 'test.dart');
var nestedFileUri = pathContext.toUri(nestedFilePath);
newFile(nestedFilePath, '');
newPubspecYamlFile(projectFolderPath, '');
// The project folder shouldn't be added to start with.
await initialize(allowEmptyRootUri: true);
expect(server.contextManager.includedPaths, equals([]));
// Opening a file nested within the project should add the project folder.
await openFile(nestedFileUri, '');
await contextRebuildComplete;
expect(server.contextManager.includedPaths, equals([projectFolderPath]));
// Ensure the file was cached in each driver. This happens as a result of
// adding to priority files, but if that's done before the file is in an
// analysis root it will not occur.
// https://github.com/dart-lang/sdk/issues/37338
for (var driver in server.driverMap.values) {
expect(driver.getCachedResolvedUnit(nestedFilePath), isNotNull);
}
// Closing the file should remove it.
await closeFile(nestedFileUri);
expect(server.contextManager.includedPaths, equals([]));
}
Future<void> test_excludedFolders_absolute() async {
var excludedFolderPath = join(projectFolderPath, 'excluded');
await provideConfig(
initialize,
// Exclude the folder with a relative path.
{
'analysisExcludedFolders': [excludedFolderPath]
},
);
expect(server.contextManager.includedPaths, equals([projectFolderPath]));
expect(server.contextManager.excludedPaths, equals([excludedFolderPath]));
}
Future<void> test_excludedFolders_nonList() async {
var excludedFolderPath = join(projectFolderPath, 'excluded');
await provideConfig(
initialize,
// Include a single string instead of an array since it's an easy mistake
// to make without editor validation of settings.
{'analysisExcludedFolders': 'excluded'},
);
expect(server.contextManager.includedPaths, equals([projectFolderPath]));
expect(server.contextManager.excludedPaths, equals([excludedFolderPath]));
}
Future<void> test_excludedFolders_relative() async {
var excludedFolderPath = join(projectFolderPath, 'aaa', 'bbb');
await provideConfig(
initialize,
// Exclude the folder with a relative path.
{
'analysisExcludedFolders': ['aaa', 'bbb'].join(pathContext.separator)
},
);
expect(server.contextManager.includedPaths, equals([projectFolderPath]));
expect(server.contextManager.excludedPaths, equals([excludedFolderPath]));
}
/// Check exclusions using explicit posix paths even if we're running
/// on Windows. It's common for shared configs in VS Code to always use
/// forward slashes in paths.
Future<void> test_excludedFolders_relative_posix() async {
var excludedFolderPath = join(projectFolderPath, 'aaa', 'bbb');
await provideConfig(
initialize,
// Exclude the folder with a relative path using forward slashes.
{
'analysisExcludedFolders': ['aaa', 'bbb'].join('/')
},
);
expect(server.contextManager.includedPaths, equals([projectFolderPath]));
expect(server.contextManager.excludedPaths, equals([excludedFolderPath]));
}
Future<void> test_excludedFolders_trailingSlash() async {
var excludedFolderPath = join(projectFolderPath, 'aaa', 'bbb');
await provideConfig(
initialize,
// Exclude the folder with a trailing slash.
{
'analysisExcludedFolders':
['aaa', 'bbb', ''].join(pathContext.separator),
},
);
expect(server.contextManager.includedPaths, equals([projectFolderPath]));
expect(server.contextManager.excludedPaths, equals([excludedFolderPath]));
}
/// Check exclusions using explicit posix paths even if we're running
/// on Windows. It's common for shared configs in VS Code to always use
/// forward slashes in paths.
Future<void> test_excludedFolders_trailingSlash_posix() async {
var excludedFolderPath = join(projectFolderPath, 'aaa', 'bbb');
await provideConfig(
initialize,
// Exclude the folder with a forward trailing slash.
{
'analysisExcludedFolders': ['aaa', 'bbb', ''].join('/'),
},
);
expect(server.contextManager.includedPaths, equals([projectFolderPath]));
expect(server.contextManager.excludedPaths, equals([excludedFolderPath]));
}
/// Tests that requests that requires a unit result are handled correctly even
/// if sent immediately after the `initialized` notification and do not result
/// in "File not Analyzed"-style errors because roots are set asynchronously.
Future<void> test_immediateRequests() async {
newFile(mainFilePath, 'void f() {}');
late Future<Either2<List<DocumentSymbol>, List<SymbolInformation>>> result;
await provideConfig(
() => initialize(
immediatelyAfterInitialized: () {
result = getDocumentSymbols(mainFileUri);
},
),
{},
);
var symbols = (await result).map(
(docSymbols) => docSymbols,
(symbolInfos) => symbolInfos,
);
expect(symbols, hasLength(1));
}
Future<void> test_initialize() async {
var response = await initialize();
expect(response, isNotNull);
expect(response.error, isNull);
expect(response.result, isNotNull);
expect(InitializeResult.canParse(response.result, nullLspJsonReporter),
isTrue);
var result =
InitializeResult.fromJson(response.result as Map<String, Object?>);
expect(result.capabilities, isNotNull);
// Check some basic capabilities that are unlikely to change.
expect(result.capabilities.textDocumentSync, isNotNull);
result.capabilities.textDocumentSync!.map(
(_) =>
throw 'Expected textDocumentSync capabilities to be a TextDocumentSyncOptions',
(options) {
// We'll always request open/closed notifications and incremental updates.
expect(options.openClose, isTrue);
expect(options.change, equals(TextDocumentSyncKind.Incremental));
},
);
}
Future<void> test_initialize_invalidParams() async {
var params = {'processId': 'invalid'};
var request = RequestMessage(
id: Either2<int, String>.t1(1),
method: Method.initialize,
params: params,
jsonrpc: jsonRpcVersion,
);
var response = await sendRequestToServer(request);
expect(response.id, equals(request.id));
expect(response.error, isNotNull);
expect(response.error!.code, equals(ErrorCodes.InvalidParams));
expect(response.result, isNull);
}
Future<void> test_initialize_onlyAllowedOnce() async {
await initialize();
var response = await initialize(throwOnFailure: false);
expect(response, isNotNull);
expect(response.result, isNull);
expect(response.error, isNotNull);
expect(response.error!.code,
equals(ServerErrorCodes.ServerAlreadyInitialized));
}
Future<void> test_initialize_rootPath() async {
await initialize(rootPath: projectFolderPath);
expect(server.contextManager.includedPaths, equals([projectFolderPath]));
}
Future<void> test_initialize_rootPath_trailingSlash() async {
await initialize(rootPath: withTrailingSlash(projectFolderPath));
expect(server.contextManager.includedPaths, equals([projectFolderPath]));
}
Future<void> test_initialize_rootUri() async {
await initialize(rootUri: projectFolderUri);
expect(server.contextManager.includedPaths, equals([projectFolderPath]));
}
Future<void> test_initialize_rootUri_encodedDriveLetterColon() async {
await initialize(rootUri: withEncodedDriveLetterColon(projectFolderUri));
expect(server.contextManager.includedPaths, equals([projectFolderPath]));
}
Future<void> test_initialize_rootUri_trailingSlash() async {
await initialize(rootUri: withTrailingSlashUri(projectFolderUri));
expect(server.contextManager.includedPaths, equals([projectFolderPath]));
}
Future<void> test_initialize_workspaceFolders() async {
await initialize(workspaceFolders: [projectFolderUri]);
expect(server.contextManager.includedPaths, equals([projectFolderPath]));
}
Future<void>
test_initialize_workspaceFolders_encodedDriveLetterColon() async {
await initialize(
workspaceFolders: [withEncodedDriveLetterColon(projectFolderUri)]);
expect(server.contextManager.includedPaths, equals([projectFolderPath]));
}
Future<void> test_initialize_workspaceFolders_trailingSlash() async {
await initialize(
workspaceFolders: [withTrailingSlashUri(projectFolderUri)]);
expect(server.contextManager.includedPaths, equals([projectFolderPath]));
}
Future<void> test_invalidExperimental_commands() async {
await expectInvalidExperimentalParams(
{'commands': 1},
'ClientCapabilities.experimental.commands must be a List<String>?',
);
}
Future<void> test_invalidExperimental_dartCodeAction() async {
await expectInvalidExperimentalParams(
{'dartCodeAction': 1},
'ClientCapabilities.experimental.dartCodeAction must be a Map<String, Object?>?',
);
}
Future<void>
test_invalidExperimental_dartCodeAction_commandParameterSupport() async {
await expectInvalidExperimentalParams(
{
'dartCodeAction': {'commandParameterSupport': 1}
},
'ClientCapabilities.experimental.dartCodeAction.commandParameterSupport must be a Map<String, Object?>?',
);
}
Future<void>
test_invalidExperimental_dartCodeAction_commandParameterSupport_supportedKinds() async {
await expectInvalidExperimentalParams(
{
'dartCodeAction': {
'commandParameterSupport': {'supportedKinds': 1}
}
},
'ClientCapabilities.experimental.dartCodeAction.commandParameterSupport.supportedKinds must be a List<String>?',
);
}
Future<void> test_invalidExperimental_snippetTextEdit() async {
await expectInvalidExperimentalParams(
{'snippetTextEdit': 1},
'ClientCapabilities.experimental.snippetTextEdit must be a bool?',
);
}
Future<void>
test_invalidExperimental_supportsDartTextDocumentContentProvider() async {
await expectInvalidExperimentalParams(
{'supportsDartTextDocumentContentProvider': 1},
'ClientCapabilities.experimental.supportsDartTextDocumentContentProvider must be a bool?',
);
}
Future<void> test_nonFileScheme_rootUri() async {
// We expect an error notification about the invalid file we try to open.
failTestOnAnyErrorNotification = false;
var rootUri = Uri.parse('vsls://');
var fileUri = rootUri.replace(path: '/file1.dart');
await initialize(rootUri: rootUri);
expect(server.contextManager.includedPaths, equals([]));
// Also open a non-file file to ensure it doesn't cause the root to be added.
await openFile(fileUri, '');
expect(server.contextManager.includedPaths, equals([]));
}
/// Verifies a workspace that contains non-file workspace folders is handled.
///
/// Related tests for didChangeWorkspaceFolders are in
/// [ChangeWorkspaceFoldersTest].
Future<void> test_nonFileScheme_workspaceFolders() async {
// We expect an error notification about the invalid file we try to open.
failTestOnAnyErrorNotification = false;
newPubspecYamlFile(projectFolderPath, '');
var rootUri = Uri.parse('vsls://');
var fileUri = rootUri.replace(path: '/file1.dart');
await initialize(
workspaceFolders: [
rootUri,
pathContext.toUri(projectFolderPath),
],
);
expect(server.contextManager.includedPaths, equals([projectFolderPath]));
// Also open a non-file file to ensure it doesn't cause the root to be added.
await openFile(fileUri, '');
expect(server.contextManager.includedPaths, equals([projectFolderPath]));
}
Future<void> test_nonProjectFiles_basicWorkspace() async {
var file1 = convertPath('/home/nonProject/file1.dart');
newFile(file1, '');
await initialize(allowEmptyRootUri: true);
// Because the file is not in a project, it should be added itself.
await openFile(pathContext.toUri(file1), '');
expect(server.contextManager.includedPaths, equals([file1]));
}
Future<void> test_nonProjectFiles_blazeWorkspace() async {
var file1 = convertPath('/home/nonProject/file1.dart');
newFile(file1, '');
// Make /home a Blaze workspace.
newFile('/home/${file_paths.blazeWorkspaceMarker}', '');
await initialize(allowEmptyRootUri: true);
// Because the file is not in a project, it should be added itself.
await openFile(pathContext.toUri(file1), '');
expect(server.contextManager.includedPaths, equals([file1]));
}
Future<void> test_onlyAnalyzeProjectsWithOpenFiles_fullyInitializes() async {
// Enable some dynamic registrations, else registerCapability will not
// be called.
setAllSupportedTextDocumentDynamicRegistrations();
// Ensure when we use onlyAnalyzeProjectsWithOpenFiles that we still
// fully initialize (eg. capabilities are registered).
projectFolderPath = convertPath('/home/empty');
await expectRequest(
Method.client_registerCapability,
() => initialize(
rootUri: projectFolderUri,
initializationOptions: {'onlyAnalyzeProjectsWithOpenFiles': true},
),
);
}
Future<void> test_onlyAnalyzeProjectsWithOpenFiles_multipleFiles() async {
var file1 = join(projectFolderPath, 'file1.dart');
var file1Uri = pathContext.toUri(file1);
newFile(file1, '');
var file2 = join(projectFolderPath, 'file2.dart');
var file2Uri = pathContext.toUri(file2);
newFile(file2, '');
newPubspecYamlFile(projectFolderPath, '');
await initialize(
rootUri: projectFolderUri,
initializationOptions: {'onlyAnalyzeProjectsWithOpenFiles': true},
);
// Opening both files should only add the project folder once.
await openFile(file1Uri, '');
await openFile(file2Uri, '');
await contextRebuildComplete;
expect(server.contextManager.includedPaths, equals([projectFolderPath]));
expect(server.contextManager.driverMap, hasLength(1));
// Closing only one of the files should not remove the root or rebuild the context.
resetContextBuildCounter();
await closeFile(file1Uri);
await pumpEventQueue(times: 5000);
expect(server.contextManager.includedPaths, equals([projectFolderPath]));
expect(server.contextManager.driverMap, hasLength(1));
expectNoContextBuilds();
// Closing the last file should remove the project folder and remove
// the context.
resetContextBuildCounter();
await closeFile(file2Uri);
await contextRebuildComplete;
expect(server.contextManager.includedPaths, equals([]));
expect(server.contextManager.driverMap, hasLength(0));
expectContextBuilds();
}
Future<void> test_onlyAnalyzeProjectsWithOpenFiles_withoutPubpsec() async {
projectFolderPath = convertPath('/home/empty');
var nestedFilePath = join(
projectFolderPath, 'nested', 'deeply', 'in', 'folders', 'test.dart');
var nestedFileUri = pathContext.toUri(nestedFilePath);
newFile(nestedFilePath, '');
// The project folder shouldn't be added to start with.
await initialize(
rootUri: projectFolderUri,
initializationOptions: {'onlyAnalyzeProjectsWithOpenFiles': true},
);
expect(server.contextManager.includedPaths, equals([]));
// Opening the file should trigger it to be added.
await openFile(nestedFileUri, '');
expect(server.contextManager.includedPaths, equals([nestedFilePath]));
}
Future<void> test_onlyAnalyzeProjectsWithOpenFiles_withPubpsec() async {
var nestedFilePath = join(
projectFolderPath, 'nested', 'deeply', 'in', 'folders', 'test.dart');
var nestedFileUri = pathContext.toUri(nestedFilePath);
newFile(nestedFilePath, '');
newPubspecYamlFile(projectFolderPath, '');
// The project folder shouldn't be added to start with.
await initialize(
rootUri: projectFolderUri,
initializationOptions: {'onlyAnalyzeProjectsWithOpenFiles': true},
);
expect(server.contextManager.includedPaths, equals([]));
// Opening a file nested within the project should cause the project folder
// to be added
await openFile(nestedFileUri, '');
await contextRebuildComplete;
expect(server.contextManager.includedPaths, equals([projectFolderPath]));
// Ensure the file was cached in each driver. This happens as a result of
// adding to priority files, but if that's done before the file is in an
// analysis root it will not occur.
// https://github.com/dart-lang/sdk/issues/37338
for (var driver in server.driverMap.values) {
expect(driver.getCachedResolvedUnit(nestedFilePath), isNotNull);
}
// Closing the file should remove it.
await closeFile(nestedFileUri);
expect(server.contextManager.includedPaths, equals([]));
}
Future<void> test_survey_alwaysEnabledForLsp() async {
await initialize();
expect(server.surveyManager, isNotNull);
}
Future<void> test_uninitialized_dropsNotifications() async {
var notification =
makeNotification(Method.fromJson('randomNotification'), null);
var nextNotification = errorNotificationsFromServer.first;
channel.sendNotificationToServer(notification);
// Wait up to 1sec to ensure no error/log notifications were sent back.
var didTimeout = false;
var notificationFromServer = await nextNotification
.then<NotificationMessage?>((notification) => notification)
.timeout(
const Duration(seconds: 1),
onTimeout: () {
didTimeout = true;
return null;
},
);
expect(notificationFromServer, isNull);
expect(didTimeout, isTrue);
}
Future<void> test_uninitialized_rejectsRequests() async {
var request = makeRequest(Method.fromJson('randomRequest'), null);
var response = await channel.sendRequestToServer(request);
expect(response.id, equals(request.id));
expect(response.result, isNull);
expect(response.error, isNotNull);
expect(response.error!.code, ErrorCodes.ServerNotInitialized);
}
}
/// Runs all initialization tests with a resource provider that slowly
/// initializes watchers to simulate delays in real fs watchers.
@reflectiveTest
class SlowInitializationTest extends InitializationTest {
@override
MemoryResourceProvider resourceProvider = MemoryResourceProvider(
// Force the in-memory file watchers to be slowly initialized to emulate
// the physical watchers (for test_concurrentContextRebuilds).
delayWatcherInitialization: Duration(milliseconds: 1),
);
}