blob: 75a29be2c095aab4ffd3066ca19b9d510612144e [file] [log] [blame]
// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:math' as math;
import 'package:analysis_server/lsp_protocol/protocol_custom_generated.dart';
import 'package:analysis_server/lsp_protocol/protocol_generated.dart';
import 'package:analysis_server/lsp_protocol/protocol_special.dart';
import 'package:analysis_server/protocol/protocol_generated.dart';
import 'package:analysis_server/src/domains/completion/available_suggestions.dart';
import 'package:analysis_server/src/lsp/client_capabilities.dart';
import 'package:analysis_server/src/lsp/handlers/handlers.dart';
import 'package:analysis_server/src/lsp/lsp_analysis_server.dart';
import 'package:analysis_server/src/lsp/mapping.dart';
import 'package:analysis_server/src/provisional/completion/completion_core.dart';
import 'package:analysis_server/src/services/completion/completion_performance.dart';
import 'package:analysis_server/src/services/completion/dart/completion_manager.dart';
import 'package:analysis_server/src/services/completion/filtering/fuzzy_matcher.dart';
import 'package:analysis_server/src/services/completion/yaml/analysis_options_generator.dart';
import 'package:analysis_server/src/services/completion/yaml/fix_data_generator.dart';
import 'package:analysis_server/src/services/completion/yaml/pubspec_generator.dart';
import 'package:analysis_server/src/services/completion/yaml/yaml_completion_generator.dart';
import 'package:analysis_server/src/services/snippets/dart/snippet_manager.dart';
import 'package:analyzer/dart/analysis/results.dart';
import 'package:analyzer/dart/analysis/session.dart';
import 'package:analyzer/dart/ast/ast.dart' as ast;
import 'package:analyzer/source/line_info.dart';
import 'package:analyzer/src/services/available_declarations.dart';
import 'package:analyzer/src/util/file_paths.dart' as file_paths;
import 'package:analyzer/src/util/performance/operation_performance.dart';
import 'package:analyzer_plugin/protocol/protocol_common.dart';
import 'package:analyzer_plugin/protocol/protocol_generated.dart' as plugin;
import 'package:analyzer_plugin/src/utilities/completion/completion_target.dart';
class CompletionHandler extends MessageHandler<CompletionParams, CompletionList>
with LspPluginRequestHandlerMixin {
/// Whether to include symbols from libraries that have not been imported.
final bool suggestFromUnimportedLibraries;
CompletionHandler(super.server, LspInitializationOptions options)
: suggestFromUnimportedLibraries = options.suggestFromUnimportedLibraries;
Method get handlesMessage => Method.textDocument_completion;
LspJsonHandler<CompletionParams> get jsonHandler =>
Future<ErrorOr<CompletionList>> handle(CompletionParams params,
MessageInfo message, CancellationToken token) async {
final clientCapabilities = server.clientCapabilities;
if (clientCapabilities == null) {
// This should not happen unless a client misbehaves.
return serverNotInitializedError;
final requestLatency = message.timeSinceRequest;
final triggerCharacter = params.context?.triggerCharacter;
final pos = params.position;
final path = pathOfDoc(params.textDocument);
// This handler is frequently called while the user is typing, which means
// during any `await` there is a good chance of the file contents being
// updated, but we must return results consistent with the file at the time
// this request started so that the client can compensate for any typing
// in the meantime.
// To do this, tell the server to lock requests until we have a resolved
// unit and LineInfo.
late ErrorOr<LineInfo> lineInfo;
late ErrorOr<ResolvedUnitResult> unit;
await server.lockRequestsWhile(() async {
unit = await path.mapResult(requireResolvedUnit);
lineInfo = await
// If we don't have a unit, we can still try to obtain the line info from
// the server (this could be because the file is non-Dart, such as YAML or
// another handled by a plugin).
(error) => path.mapResult(getLineInfo),
(unit) => success(unit.lineInfo),
if (token.isCancellationRequested) {
return cancelled();
// Map the offset, propagating the previous failure if we didn't have a
// valid LineInfo.
final offsetResult = !lineInfo.isError
? toOffset(lineInfo.result, pos)
: failure<int>(lineInfo);
if (offsetResult.isError) {
return failure(offsetResult);
final offset = offsetResult.result;
Future<ErrorOr<_CompletionResults>>? serverResultsFuture;
final pathContext = server.resourceProvider.pathContext;
final fileExtension = pathContext.extension(path.result);
final maxResults =
CompletionPerformance? completionPerformance;
if (fileExtension == '.dart' && !unit.isError) {
final result = unit.result;
var performance = OperationPerformanceImpl('<root>');
serverResultsFuture = performance.runAsync(
(performance) async {
final thisPerformance = CompletionPerformance(
operation: performance,
path: result.path,
requestLatency: requestLatency,
content: result.content,
offset: offset,
completionPerformance = thisPerformance;
// `await` required for `performance.runAsync` to count time.
return await _getServerDartItems(
} else if (fileExtension == '.yaml') {
YamlCompletionGenerator? generator;
if (file_paths.isAnalysisOptionsYaml(pathContext, path.result)) {
generator = AnalysisOptionsGenerator(server.resourceProvider);
} else if (file_paths.isFixDataYaml(pathContext, path.result)) {
generator = FixDataGenerator(server.resourceProvider);
} else if (file_paths.isPubspecYaml(pathContext, path.result)) {
generator =
PubspecGenerator(server.resourceProvider, server.pubPackageService);
if (generator != null) {
serverResultsFuture = _getServerYamlItems(
serverResultsFuture ??=
Future.value(success(_CompletionResults(isIncomplete: false)));
final pluginResultsFuture = _getPluginResults(
clientCapabilities, lineInfo.result, path.result, offset);
final serverResults = await serverResultsFuture;
final pluginResults = await pluginResultsFuture;
if (serverResults.isError) return failure(serverResults);
if (pluginResults.isError) return failure(pluginResults);
final untruncatedRankedItems = serverResults.result.rankedItems
final unrankedItems = serverResults.result.unrankedItems;
// Truncate ranked items to allow room for all unranked items.
final maxRankedItems = math.max(maxResults - unrankedItems.length, 0);
final truncatedRankedItems = untruncatedRankedItems.length > maxRankedItems
? (untruncatedRankedItems..sort(sortTextComparer))
.sublist(0, maxRankedItems)
: untruncatedRankedItems;
final truncatedItems =
// If we're tracing performance (only Dart), record the number of results
// after truncation.
completionPerformance?.transmittedSuggestionCount = truncatedItems.length;
return success(CompletionList(
// If any set of the results is incomplete, the whole batch must be
// marked as such.
isIncomplete: serverResults.result.isIncomplete ||
pluginResults.result.isIncomplete ||
truncatedRankedItems.length != untruncatedRankedItems.length,
items: truncatedItems,
/// Build a list of existing imports so we can filter out any suggestions
/// that resolve to the same underlying declared symbol.
/// Map with key "elementName/elementDeclaringLibraryUri"
/// Value is a set of imported URIs that import that element.
Map<String, Set<String>> _buildLookupOfImportedSymbols(
ResolvedUnitResult unit) {
final alreadyImportedSymbols = <String, Set<String>>{};
final importElementList = unit.libraryElement.imports;
for (var import in importElementList) {
final importedLibrary = import.importedLibrary;
if (importedLibrary == null) continue;
for (var element in import.namespace.definedNames.values) {
final librarySource = element.librarySource;
final elementName =;
if (librarySource != null && elementName != null) {
final declaringLibraryUri = librarySource.uri;
final key =
_createImportedSymbolKey(elementName, declaringLibraryUri);
.putIfAbsent(key, () => <String>{})
return alreadyImportedSymbols;
/// The insert length is the shorter of the replacementLength or the
/// difference between the replacementOffset and the caret position.
int _computeInsertLength(
int offset, int replacementOffset, int replacementLength) {
final insertLength =
math.min(offset - replacementOffset, replacementLength);
assert(insertLength >= 0);
assert(insertLength <= replacementLength);
return insertLength;
String _createImportedSymbolKey(String name, Uri declaringUri) =>
Future<Iterable<CompletionItem>> _getDartSnippetItems({
required LspClientCapabilities clientCapabilities,
required ResolvedUnitResult unit,
required int offset,
required LineInfo lineInfo,
}) async {
final request = DartSnippetRequest(
unit: unit,
offset: offset,
final snippetManager = DartSnippetManager();
final snippets = await snippetManager.computeSnippets(request);
return => snippetToCompletionItem(
Future<ErrorOr<CompletionList>> _getPluginResults(
LspClientCapabilities capabilities,
LineInfo lineInfo,
String path,
int offset,
) async {
final requestParams = plugin.CompletionGetSuggestionsParams(path, offset);
final pluginResponses = await requestFromPlugins(path, requestParams,
timeout: const Duration(milliseconds: 100));
final pluginResults = pluginResponses
.map((e) => plugin.CompletionGetSuggestionsResult.fromResponse(e))
return success(CompletionList(
isIncomplete: false,
items: _pluginResultsToItems(
Future<ErrorOr<_CompletionResults>> _getServerDartItems(
LspClientCapabilities capabilities,
ResolvedUnitResult unit,
CompletionPerformance completionPerformance,
OperationPerformanceImpl performance,
int offset,
String? triggerCharacter,
CancellationToken token,
) async {
final useSuggestionSets =
suggestFromUnimportedLibraries && capabilities.applyEdit;
final completionRequest = DartCompletionRequest.forResolvedUnit(
resolvedUnit: unit,
offset: offset,
dartdocDirectiveInfo: server.getDartdocDirectiveInfoFor(unit),
completionPreference: CompletionPreference.replace,
final target =;
final fuzzy = _FuzzyFilterHelper(completionRequest.targetPrefix);
if (triggerCharacter != null) {
if (!_triggerCharacterValid(offset, triggerCharacter, target)) {
return success(_CompletionResults(isIncomplete: false));
Set<ElementKind>? includedElementKinds;
Set<String>? includedElementNames;
List<IncludedSuggestionRelevanceTag>? includedSuggestionRelevanceTags;
if (useSuggestionSets) {
includedElementKinds = <ElementKind>{};
includedElementNames = <String>{};
includedSuggestionRelevanceTags = <IncludedSuggestionRelevanceTag>[];
try {
final serverSuggestions2 =
await performance.runAsync('computeSuggestions', (performance) async {
var contributor = DartCompletionManager(
budget: CompletionBudget(CompletionBudget.defaultDuration),
includedElementKinds: includedElementKinds,
includedElementNames: includedElementNames,
includedSuggestionRelevanceTags: includedSuggestionRelevanceTags,
// `await` required for `performance.runAsync` to count time.
return await contributor.computeSuggestions(
final serverSuggestions ='buildSuggestions', (performance) {
return serverSuggestions2
.map((serverSuggestion) =>
final insertLength = _computeInsertLength(
if (token.isCancellationRequested) {
return cancelled();
/// completeFunctionCalls should be suppressed if the target is an
/// invocation that already has an argument list, otherwise we would
/// insert dupes.
final completeFunctionCalls = _hasExistingArgList(target.entity)
? false
/// Helper to convert [CompletionSuggestions] to [CompletionItem].
CompletionItem suggestionToCompletionItem(CompletionSuggestion item) {
var itemReplacementOffset =
item.replacementOffset ?? completionRequest.replacementOffset;
var itemReplacementLength =
item.replacementLength ?? completionRequest.replacementLength;
var itemInsertLength = insertLength;
// Recompute the insert length if it may be affected by the above.
if (item.replacementOffset != null || item.replacementLength != null) {
itemInsertLength = _computeInsertLength(
offset, itemReplacementOffset, itemInsertLength);
// Convert to LSP ranges using the LineInfo.
Range? replacementRange = toRange(
unit.lineInfo, itemReplacementOffset, itemReplacementLength);
Range? insertionRange =
toRange(unit.lineInfo, itemReplacementOffset, itemInsertLength);
return toCompletionItem(
replacementRange: replacementRange,
insertionRange: insertionRange,
// TODO(dantup): Move commit characters to the main response
// and remove from each individual item (to reduce payload size)
// once the following change ships (and the Dart VS Code
// extension is updated to use it).
completeFunctionCalls: completeFunctionCalls,
final rankedResults ='mapSuggestions', (performance) {
return serverSuggestions
// Now compute items in suggestion sets.
var includedSuggestionSets = <IncludedSuggestionSet>[];
final declarationsTracker = server.declarationsTracker;
if (declarationsTracker != null &&
includedElementKinds != null &&
includedElementNames != null &&
includedSuggestionRelevanceTags != null) {'computeIncludedSetList', (performance) {
// Checked in `if` above.
// Build a fast lookup for imported symbols so that we can filter out
// duplicates.
final alreadyImportedSymbols ='_buildLookupOfImportedSymbols', (performance) {
return _buildLookupOfImportedSymbols(unit);
/// Helper to check existing imports to ensure we don't already import
/// this element (this exact element from its declaring
/// library, not just something with the same name). If we do
/// we'll want to skip it.
bool isNotImportedOrLibraryIsFirst(Declaration item, Library library) {
final declaringUri =
item.parent?.locationLibraryUri ?? item.locationLibraryUri!;
// For enums and named constructors, only the parent enum/class is in
// the list of imported symbols so we use the parents name.
final nameKey = item.kind == DeclarationKind.ENUM_CONSTANT ||
item.kind == DeclarationKind.CONSTRUCTOR
? item.parent!.name
final key = _createImportedSymbolKey(nameKey, declaringUri);
final importingUris = alreadyImportedSymbols[key];
// Keep it only if:
// - no existing imports include it
// (in which case all libraries will be offered as
// auto-imports)
// - this is the first imported URI that includes it
// (we don't want to repeat it for each imported library that
// includes it)
return importingUris == null ||
importingUris.first == '${library.uri}';
/// Helper to filter to only the kinds we should return.
bool shouldIncludeKind(Declaration item) =>
// Only specific types of child declarations should be included.
// This list matches what's in _protocolAvailableSuggestion in
// the DAS implementation.
bool shouldIncludeChild(Declaration child) =>
child.kind == DeclarationKind.CONSTRUCTOR ||
child.kind == DeclarationKind.ENUM_CONSTANT ||
(child.kind == DeclarationKind.GETTER && child.isStatic) ||
(child.kind == DeclarationKind.FIELD && child.isStatic);'addIncludedSuggestionSets', (performance) {
// Checked in `if` above.
// Make a fast lookup for tag relevance.
final tagBoosts = <String, int>{};
for (final t in includedSuggestionRelevanceTags) {
tagBoosts[t.tag] = t.relevanceBoost;
for (final includedSet in includedSuggestionSets) {
final library = declarationsTracker.getLibrary(;
if (library == null) {
// Collect declarations and their children.
final setResults = library.declarations
.expand((decl) => decl.children.where(shouldIncludeChild)))
.where((Declaration item) =>
isNotImportedOrLibraryIsFirst(item, library))
.map((item) => declarationToCompletionItem(
// TODO(dantup): Move commit characters to the main response
// and remove from each individual item (to reduce payload size)
// once the following change ships (and the Dart VS Code
// extension is updated to use it).
includeCommitCharacters: server,
completeFunctionCalls: completeFunctionCalls,
// Add in any snippets.
final snippetsEnabled =
// We can only produce edits with edit builders for files inside
// the root, so skip snippets entirely if not.
final isEditableFile =
List<CompletionItem> unrankedResults;
if (capabilities.completionSnippets &&
snippetsEnabled &&
isEditableFile) {
unrankedResults =
await performance.runAsync('getSnippets', (performance) async {
final snippets = await _getDartSnippetItems(
clientCapabilities: capabilities,
unit: unit,
offset: offset,
lineInfo: unit.lineInfo,
return snippets.where(fuzzy.completionItemMatches).toList();
} else {
unrankedResults = [];
// transmittedCount will be set after combining with plugins + truncation.
completionPerformance.computedSuggestionCount =
rankedResults.length + unrankedResults.length;
return success(_CompletionResults(
isIncomplete: false,
rankedItems: rankedResults,
unrankedItems: unrankedResults));
} on AbortCompletion {
return success(_CompletionResults(isIncomplete: false));
} on InconsistentAnalysisException {
return success(_CompletionResults(isIncomplete: false));
Future<ErrorOr<_CompletionResults>> _getServerYamlItems(
YamlCompletionGenerator generator,
LspClientCapabilities capabilities,
String path,
LineInfo lineInfo,
int offset,
CancellationToken token,
) async {
final suggestions = generator.getSuggestions(path, offset);
final insertLength = _computeInsertLength(
final replacementRange = toRange(
lineInfo, suggestions.replacementOffset, suggestions.replacementLength);
final insertionRange =
toRange(lineInfo, suggestions.replacementOffset, insertLength);
// Perform fuzzy matching based on the identifier in front of the caret to
// reduce the size of the payload.
final fuzzyPattern = suggestions.targetPrefix;
final fuzzyMatcher =
FuzzyMatcher(fuzzyPattern, matchStyle: MatchStyle.TEXT);
final completionItems = suggestions.suggestions
.where((item) =>
fuzzyMatcher.score(item.displayText ?? item.completion) > 0)
(item) => toCompletionItem(
replacementRange: replacementRange,
insertionRange: insertionRange,
includeCommitCharacters: false,
completeFunctionCalls: false,
// Add on any completion-kind-specific resolution data that will be
// used during resolve() calls to provide additional information.
resolutionData: item.kind == CompletionSuggestionKind.PACKAGE_NAME
? PubPackageCompletionItemResolutionInfo(
// The completion for package names may contain a trailing
// ': ' for convenience, so if it's there, trim it off.
packageName: item.completion.split(':').first,
: null,
return success(_CompletionResults(
isIncomplete: false, unrankedItems: completionItems));
/// Returns true if [node] is part of an invocation and already has an argument
/// list.
bool _hasExistingArgList(Object? node) {
// print^('foo');
if (node is ast.ExpressionStatement) {
node = node.expression;
if (node is ast.SimpleIdentifier) {
node = node.parent;
// new^()
if (node is ast.ConstructorName) {
node = node.parent;
return (node is ast.InvocationExpression &&
!node.argumentList.beginToken.isSynthetic) ||
(node is ast.InstanceCreationExpression &&
!node.argumentList.beginToken.isSynthetic) ||
// "ClassName.^()" will appear as accessing a property named '('.
(node is ast.PropertyAccess &&'('));
Iterable<CompletionItem> _pluginResultsToItems(
LspClientCapabilities capabilities,
LineInfo lineInfo,
int offset,
List<plugin.CompletionGetSuggestionsResult> pluginResults,
) {
return pluginResults.expand((result) {
final insertLength = _computeInsertLength(
final replacementRange =
toRange(lineInfo, result.replacementOffset, result.replacementLength);
final insertionRange =
toRange(lineInfo, result.replacementOffset, insertLength);
(item) => toCompletionItem(
replacementRange: replacementRange,
insertionRange: insertionRange,
// Plugins cannot currently contribute commit characters and we should
// not assume that the Dart ones would be correct for all of their
// completions.
includeCommitCharacters: false,
completeFunctionCalls: false,
/// Checks whether the given [triggerCharacter] is valid for [target].
/// Some trigger characters are only valid in certain locations, for example
/// a single quote ' is valid to trigger completion after typing an import
/// statement, but not when terminating a string. The client has no context
/// and sends the requests unconditionally.
bool _triggerCharacterValid(
int offset, String triggerCharacter, CompletionTarget target) {
final node = target.containingNode;
switch (triggerCharacter) {
// For quotes, it's only valid if we're right after the opening quote of a
// directive.
case '"':
case "'":
return node is ast.SimpleStringLiteral &&
node.parent is ast.Directive &&
offset == node.contentsOffset;
// Braces only for starting interpolated expressions.
case '{':
return node is ast.InterpolationExpression &&
node.expression.offset == offset;
// Slashes only as path separators in directives.
case '/':
return node is ast.SimpleStringLiteral &&
node.parent is ast.Directive &&
offset >= node.contentsOffset &&
offset <= node.contentsEnd;
return true; // Any other trigger character can be handled always.
/// Compares [CompletionItem]s by the `sortText` field, which is derived from
/// relevance.
/// For items with the same relevance, shorter items are sorted first so that
/// truncation always removes longer items first (which can be included by
/// typing more of their characters).
static int sortTextComparer(CompletionItem item1, CompletionItem item2) {
// Note: It should never be the case that we produce items without sortText
// but if they're null, fall back to label which is what the client would do
// when sorting.
final item1Text = item1.sortText ?? item1.label;
final item2Text = item2.sortText ?? item2.label;
// If both items have the same text, this means they had the same relevance.
// In this case, sort by the length of the name ascending, so that shorter
// items are first. This is because longer items can be obtained by typing
// additional characters where shorter ones may not.
// For example, with:
// - String aaa1;
// - String aaa2;
// - ...
// - String aaa(N); // up to past the truncation amount
// - String aaa; // declared last, same prefix
// Typing 'aaa' should not allow 'aaa' to be truncated before 'aaa1'.
if (item1Text == item2Text) {
return item1.label.length.compareTo(item2.label.length);
return item1Text.compareTo(item2Text);
/// A set of completion items split into ranked and unranked items.
class _CompletionResults {
/// Items that can be ranked using their relevance/sortText.
final List<CompletionItem> rankedItems;
/// Items that cannot be ranked, and should avoid being truncated.
final List<CompletionItem> unrankedItems;
final bool isIncomplete;
this.rankedItems = const [],
this.unrankedItems = const [],
required this.isIncomplete,
/// Helper to simplify fuzzy filtering.
/// Used to perform fuzzy matching based on the identifier in front of the caret to
/// reduce the size of the payload.
class _FuzzyFilterHelper {
final FuzzyMatcher _matcher;
_FuzzyFilterHelper(String prefix)
: _matcher = FuzzyMatcher(prefix, matchStyle: MatchStyle.TEXT);
bool completionItemMatches(CompletionItem item) =>
_matcher.score(item.filterText ?? item.label) > 0;
bool completionSuggestionMatches(CompletionSuggestion item) =>
_matcher.score(item.displayText ?? item.completion) > 0;
bool declarationMatches(Declaration item) =>
_matcher.score(getDeclarationName(item)) > 0;