blob: 1afff6b213bd88a9270e6f11bc51d7fb7d2d3ab4 [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.dart' hide Declaration;
import 'package:analysis_server/src/computer/computer_hover.dart';
import 'package:analysis_server/src/lsp/client_capabilities.dart';
import 'package:analysis_server/src/lsp/constants.dart';
import 'package:analysis_server/src/lsp/error_or.dart';
import 'package:analysis_server/src/lsp/handlers/handlers.dart';
import 'package:analysis_server/src/lsp/mapping.dart';
import 'package:analysis_server/src/lsp/registration/feature_registration.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/dart/dart_completion_suggestion.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_request.dart';
import 'package:analysis_server/src/services/snippets/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/util/file_paths.dart' as file_paths;
import 'package:analyzer/src/util/performance/operation_performance.dart';
import 'package:analyzer/src/utilities/fuzzy_matcher.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';
import 'package:collection/collection.dart';
import 'package:meta/meta.dart';
/// A record of a [CompletionItem] and a fuzzy score.
typedef _ScoredCompletionItem = ({CompletionItem item, double score});
class CompletionHandler
extends LspMessageHandler<CompletionParams, CompletionList>
with LspPluginRequestHandlerMixin, LspHandlerHelperMixin {
/// A [Future] used by tests to allow inserting a delay between resolving
/// the initial unit and the completion code running.
@visibleForTesting
static Future<void>? delayAfterResolveForTests;
/// Whether to include symbols from libraries that have not been imported.
final bool suggestFromUnimportedLibraries;
/// The budget to use for [NotImportedContributor] computation.
///
/// This is usually the default value, but can be overridden via
/// initializationOptions (used for tests, but may also be useful for
/// debugging).
late final Duration completionBudgetDuration;
/// A cancellation token for the previous completion request.
///
/// A new completion request will cancel the previous request. We do not allow
/// concurrent completion requests.
///
/// `null` if there is no previous request. It the previous request has
/// already completed, cancelling this token will not do anything.
CancelableToken? previousRequestCancellationToken;
CompletionHandler(super.server)
: suggestFromUnimportedLibraries =
server.initializationOptions?.suggestFromUnimportedLibraries ??
true {
var budgetMs = server.initializationOptions?.completionBudgetMilliseconds;
completionBudgetDuration = budgetMs != null
? Duration(milliseconds: budgetMs)
: CompletionBudget.defaultDuration;
}
@override
Method get handlesMessage => Method.textDocument_completion;
@override
LspJsonHandler<CompletionParams> get jsonHandler =>
CompletionParams.jsonHandler;
@override
Future<ErrorOr<CompletionList>> handle(CompletionParams params,
MessageInfo message, CancellationToken token) async {
var clientCapabilities = message.clientCapabilities;
if (clientCapabilities == null) {
// This should not happen unless a client misbehaves.
return serverNotInitializedError;
}
// Cancel any existing in-progress completion request in case the client did
// not do it explicitly, because the results will not be useful and it may
// delay processing this one.
previousRequestCancellationToken?.cancel();
previousRequestCancellationToken = token.asCancelable();
var requestLatency = message.timeSinceRequest;
var triggerCharacter = params.context?.triggerCharacter;
var pos = params.position;
var path = pathOfDoc(params.textDocument);
// IMPORTANT:
// 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 unit.map(
// 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.mapResultSync(getLineInfo),
(unit) => success(unit.lineInfo),
);
});
if (delayAfterResolveForTests != null) {
await delayAfterResolveForTests;
}
if (token.isCancellationRequested) {
return cancelled();
}
// Map the offset, propagating the previous failure if we didn't have a
// valid LineInfo.
var offset = lineInfo.mapResultSync((lineInfo) => toOffset(lineInfo, pos));
return await (path, lineInfo, offset)
.mapResults((path, lineInfo, offset) async {
var fileExtension = pathContext.extension(path);
var maxResults =
server.lspClientConfiguration.forResource(path).maxCompletionItems;
CompletionPerformance? completionPerformance;
Future<ErrorOr<_CompletionResults>>? serverResultsFuture;
if (fileExtension == '.dart') {
unit.ifResult((unit) {
var performance = message.performance;
serverResultsFuture = performance.runAsync(
'request',
(performance) async {
var thisPerformance = CompletionPerformance(
performance: performance,
path: unit.path,
requestLatency: requestLatency,
content: unit.content,
offset: offset,
);
completionPerformance = thisPerformance;
server.recentPerformance.completion.add(thisPerformance);
// `await` required for `performance.runAsync` to count time.
return await _getServerDartItems(
clientCapabilities,
unit,
thisPerformance,
performance,
offset,
triggerCharacter,
token,
maxResults,
);
},
);
});
} else if (fileExtension == '.yaml') {
YamlCompletionGenerator? generator;
if (file_paths.isAnalysisOptionsYaml(pathContext, path)) {
generator = AnalysisOptionsGenerator(server.resourceProvider);
} else if (file_paths.isFixDataYaml(pathContext, path)) {
generator = FixDataGenerator(server.resourceProvider);
} else if (file_paths.isPubspecYaml(pathContext, path)) {
generator = PubspecGenerator(
server.resourceProvider, server.pubPackageService);
}
if (generator != null) {
serverResultsFuture = _getServerYamlItems(
generator,
clientCapabilities,
path,
lineInfo,
offset,
token,
);
}
}
var pluginResultsFuture =
_getPluginResults(clientCapabilities, lineInfo, path, offset);
var serverResults =
(await serverResultsFuture) ?? success(_CompletionResults.empty());
var pluginResults = await pluginResultsFuture;
return (serverResults, pluginResults)
.mapResultsSync((serverResults, pluginResults) {
// Add in fuzzy scores for completion items.
var pluginResultItems = pluginResults.items.map((item) =>
(item: item, score: serverResults.fuzzy.completionItemScore(item)));
var untruncatedRankedItems =
serverResults.rankedItems.followedBy(pluginResultItems).toList();
var unrankedItems = serverResults.unrankedItems;
// Truncate ranked items allowing for all unranked items.
var maxRankedItems = math.max(maxResults - unrankedItems.length, 0);
var truncatedRankedItems =
untruncatedRankedItems.length <= maxRankedItems
? untruncatedRankedItems
: _truncateResults(
untruncatedRankedItems,
serverResults.targetPrefix,
maxRankedItems,
);
var truncatedItems = truncatedRankedItems
.map((item) => item.item)
.followedBy(unrankedItems)
.toList();
// 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.isIncomplete ||
pluginResults.isIncomplete ||
truncatedRankedItems.length != untruncatedRankedItems.length,
items: truncatedItems,
itemDefaults: serverResults.defaults,
));
});
});
}
/// Computes all supported defaults for completion items based on
/// [capabilities].
CompletionListItemDefaults? _computeCompletionDefaults(
LspClientCapabilities capabilities,
Range insertionRange,
Range replacementRange,
) {
// None of the items we use are set.
if (!capabilities.completionDefaultEditRange &&
!capabilities.completionDefaultTextMode) {
return null;
}
return CompletionListItemDefaults(
insertTextMode:
capabilities.completionDefaultTextMode ? InsertTextMode.asIs : null,
editRange: _computeDefaultEditRange(
capabilities, insertionRange, replacementRange),
);
}
/// Computes the default completion edit range based on [capabilities] and
/// whether the insert/replacement ranges differ.
Either2<CompletionItemEditRange, Range>? _computeDefaultEditRange(
LspClientCapabilities capabilities,
Range insertionRange,
Range replacementRange,
) {
if (!capabilities.completionDefaultEditRange) {
return null;
}
if (!capabilities.insertReplaceCompletionRanges ||
insertionRange == replacementRange) {
return Either2<CompletionItemEditRange, Range>.t2(replacementRange);
} else {
return Either2<CompletionItemEditRange, Range>.t1(
CompletionItemEditRange(
insert: insertionRange,
replace: replacementRange,
),
);
}
}
/// 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) {
var insertLength = math.min(offset - replacementOffset, replacementLength);
assert(insertLength >= 0);
assert(insertLength <= replacementLength);
return insertLength;
}
Future<Iterable<CompletionItem>> _getDartSnippetItems({
required LspClientCapabilities clientCapabilities,
required ResolvedUnitResult unit,
required int offset,
required LineInfo lineInfo,
required bool Function(String input) filter,
CompletionListItemDefaults? defaults,
}) async {
var request = DartSnippetRequest(
unit: unit,
offset: offset,
);
var snippetManager = DartSnippetManager();
var snippets =
await snippetManager.computeSnippets(request, filter: filter);
return snippets.map((snippet) => snippetToCompletionItem(
server,
clientCapabilities,
unit.path,
lineInfo,
toPosition(lineInfo.getLocation(offset)),
snippet,
defaults,
));
}
Future<ErrorOr<CompletionList>> _getPluginResults(
LspClientCapabilities capabilities,
LineInfo lineInfo,
String path,
int offset,
) async {
var requestParams = plugin.CompletionGetSuggestionsParams(path, offset);
var pluginResponses = await requestFromPlugins(path, requestParams,
timeout: const Duration(milliseconds: 100));
var pluginResults = pluginResponses
.map((e) => plugin.CompletionGetSuggestionsResult.fromResponse(e))
.toList();
return success(CompletionList(
isIncomplete: false,
items: _pluginResultsToItems(
capabilities,
path,
lineInfo,
offset,
pluginResults,
).toList(),
));
}
Future<ErrorOr<_CompletionResults>> _getServerDartItems(
LspClientCapabilities capabilities,
ResolvedUnitResult unit,
CompletionPerformance completionPerformance,
OperationPerformanceImpl performance,
int offset,
String? triggerCharacter,
CancellationToken token,
int maxSuggestions,
) async {
var useNotImportedCompletions =
suggestFromUnimportedLibraries && capabilities.applyEdit;
var completionRequest = DartCompletionRequest.forResolvedUnit(
resolvedUnit: unit,
offset: offset,
dartdocDirectiveInfo: server.getDartdocDirectiveInfoFor(unit),
completionPreference: CompletionPreference.replace,
);
var target = completionRequest.target;
var targetPrefix = completionRequest.targetPrefix;
var fuzzy = _FuzzyScoreHelper(targetPrefix);
if (triggerCharacter != null) {
if (!_triggerCharacterValid(offset, triggerCharacter, target)) {
return success(_CompletionResults.empty());
}
}
NotImportedSuggestions? notImportedSuggestions;
if (useNotImportedCompletions) {
notImportedSuggestions = NotImportedSuggestions();
}
var isIncomplete = false;
try {
var serverSuggestions2 =
await performance.runAsync('computeSuggestions', (performance) async {
var contributor = DartCompletionManager(
budget: CompletionBudget(completionBudgetDuration),
notImportedSuggestions: notImportedSuggestions,
);
var suggestions = await contributor.computeSuggestions(
completionRequest,
performance,
maxSuggestions: maxSuggestions,
useFilter: true,
);
// Keep track of whether the set of results was truncated (because
// budget was exhausted).
isIncomplete =
contributor.notImportedSuggestions?.isIncomplete ?? false;
return suggestions;
});
var serverSuggestions =
performance.run('buildSuggestions', (performance) {
return serverSuggestions2
.map((serverSuggestion) => serverSuggestion.build())
.toList();
});
var replacementOffset = completionRequest.replacementOffset;
var replacementLength = completionRequest.replacementLength;
var insertLength = _computeInsertLength(
offset,
replacementOffset,
replacementLength,
);
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.
var completeFunctionCalls = _hasExistingArgList(target.entity)
? false
: server.lspClientConfiguration.global.completeFunctionCalls;
// Compute defaults that will allow us to reduce payload size.
var defaultReplacementRange =
toRange(unit.lineInfo, replacementOffset, replacementLength);
var defaultInsertionRange =
toRange(unit.lineInfo, replacementOffset, insertLength);
var defaults = _computeCompletionDefaults(
capabilities, defaultInsertionRange, defaultReplacementRange);
/// 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.
var replacementRange = toRange(
unit.lineInfo, itemReplacementOffset, itemReplacementLength);
var insertionRange =
toRange(unit.lineInfo, itemReplacementOffset, itemInsertLength);
// For items that need imports, we'll round-trip some additional info
// to allow their additional edits (and documentation) to be handled
// lazily to reduce the payload.
CompletionItemResolutionInfo? resolutionInfo;
if (item is DartCompletionSuggestion) {
var elementLocation = item.elementLocation;
var importUris = item.requiredImports;
if (importUris.isNotEmpty) {
resolutionInfo = DartCompletionResolutionInfo(
file: unit.path,
importUris: importUris.map((uri) => uri.toString()).toList(),
ref: elementLocation?.encoding,
);
}
}
return toCompletionItem(
capabilities,
unit.lineInfo,
item,
uriConverter: uriConverter,
pathContext: pathContext,
completionFilePath: unit.path,
hasDefaultTextMode: defaults?.insertTextMode != null,
hasDefaultEditRange: defaults?.editRange != null &&
insertionRange == defaultInsertionRange &&
replacementRange == defaultReplacementRange,
replacementRange: replacementRange,
insertionRange: insertionRange,
commitCharactersEnabled:
server.lspClientConfiguration.global.previewCommitCharacters,
completeFunctionCalls: completeFunctionCalls,
resolutionData: resolutionInfo,
// Exclude docs if we will be providing them via
// `completionItem/resolve`, otherwise use users preference.
includeDocumentation: resolutionInfo != null
? DocumentationPreference.none
: server.lspClientConfiguration.global.preferredDocumentation,
);
}
var rankedResults = performance.run('mapSuggestions', (performance) {
return serverSuggestions
// Compute the fuzzy score which we can use both for filtering here
// and for truncation sorting later on.
.map((item) => (item: item, score: fuzzy.suggestionScore(item)))
// Filter out the non-matches.
.where((scoredItem) => scoredItem.score > 0)
// Convert to CompletionItem and re-attach the score to be used for
// truncation later.
.map((scoredItem) => (
item: suggestionToCompletionItem(scoredItem.item),
score: scoredItem.score
))
.toList();
});
// Add in any snippets.
var snippetsEnabled =
server.lspClientConfiguration.forResource(unit.path).enableSnippets;
// We can only produce edits with edit builders for files inside
// the root, so skip snippets entirely if not.
var isEditableFile =
unit.session.analysisContext.contextRoot.isAnalyzed(unit.path);
List<CompletionItem> unrankedResults;
if (capabilities.completionSnippets &&
snippetsEnabled &&
isEditableFile) {
// Snippets may need to obtain resolved units to produce edits in files.
// If files have been modified since we started, these will throw but
// we should not bring down the entire completion request, just exclude
// the snippets and set isIncomplete=true.
//
// VS Code assumes we will continue to service a completion request
// even when documents are modified (as the user is typing).
try {
unrankedResults =
await performance.runAsync('getSnippets', (performance) async {
// TODO(dantup): Pass `fuzzy` into here so we can filter snippets
// before computing them to avoid looking up Element->Public Library
// if they won't be included.
var snippets = await _getDartSnippetItems(
clientCapabilities: capabilities,
unit: unit,
offset: offset,
lineInfo: unit.lineInfo,
filter: fuzzy.stringMatches,
defaults: defaults,
);
return snippets.where(fuzzy.completionItemMatches).toList();
});
} on AbortCompletion {
isIncomplete = true;
unrankedResults = [];
} on InconsistentAnalysisException {
isIncomplete = true;
unrankedResults = [];
}
} else {
unrankedResults = [];
}
// transmittedCount will be set after combining with plugins + truncation.
completionPerformance.computedSuggestionCount =
rankedResults.length + unrankedResults.length;
return success(_CompletionResults(
isIncomplete: isIncomplete,
fuzzy: fuzzy,
rankedItems: rankedResults,
unrankedItems: unrankedResults,
defaults: defaults,
));
} on AbortCompletion {
return success(_CompletionResults.emptyIncomplete());
} on InconsistentAnalysisException {
return success(_CompletionResults.emptyIncomplete());
}
}
Future<ErrorOr<_CompletionResults>> _getServerYamlItems(
YamlCompletionGenerator generator,
LspClientCapabilities capabilities,
String filePath,
LineInfo lineInfo,
int offset,
CancellationToken token,
) async {
var suggestions = generator.getSuggestions(filePath, offset);
var insertLength = _computeInsertLength(
offset,
suggestions.replacementOffset,
suggestions.replacementLength,
);
var replacementRange = toRange(
lineInfo, suggestions.replacementOffset, suggestions.replacementLength);
var 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.
var fuzzyPattern = suggestions.targetPrefix;
var fuzzyMatcher = FuzzyMatcher(fuzzyPattern);
var completionItems = suggestions.suggestions
.where((item) =>
fuzzyMatcher.score(item.displayText ?? item.completion) > 0)
.map((item) {
var resolutionInfo = 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 toCompletionItem(
capabilities,
lineInfo,
item,
uriConverter: uriConverter,
pathContext: pathContext,
completionFilePath: filePath,
replacementRange: replacementRange,
insertionRange: insertionRange,
commitCharactersEnabled: false,
completeFunctionCalls: false,
// Exclude docs if we could provide them via
// `completionItem/resolve`, otherwise use users preference.
includeDocumentation: resolutionInfo != null
? DocumentationPreference.none
: server.lspClientConfiguration.global.preferredDocumentation,
// Add on any completion-kind-specific resolution data that will be
// used during resolve() calls to provide additional information.
resolutionData: resolutionInfo,
);
}).toList();
return success(
_CompletionResults.unranked(completionItems, isIncomplete: false),
);
}
/// 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;
}
// super.foo^();
if (node is ast.SimpleIdentifier) {
node = node.parent;
}
// new Aaaa.bar^()
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 && node.propertyName.name.startsWith('('));
}
Iterable<CompletionItem> _pluginResultsToItems(
LspClientCapabilities capabilities,
String filePath,
LineInfo lineInfo,
int offset,
List<plugin.CompletionGetSuggestionsResult> pluginResults,
) {
return pluginResults.expand((result) {
var insertLength = _computeInsertLength(
offset,
result.replacementOffset,
result.replacementLength,
);
var replacementRange =
toRange(lineInfo, result.replacementOffset, result.replacementLength);
var insertionRange =
toRange(lineInfo, result.replacementOffset, insertLength);
return result.results.map((item) {
var isNotImported = item.isNotImported ?? false;
var importUri = item.libraryUri;
DartCompletionResolutionInfo? resolutionInfo;
if (isNotImported && importUri != null) {
resolutionInfo = DartCompletionResolutionInfo(
file: filePath,
importUris: [importUri],
);
}
return toCompletionItem(
capabilities,
lineInfo,
item,
uriConverter: uriConverter,
pathContext: pathContext,
completionFilePath: filePath,
replacementRange: replacementRange,
insertionRange: insertionRange,
includeDocumentation:
server.lspClientConfiguration.global.preferredDocumentation,
// Plugins cannot currently contribute commit characters and we should
// not assume that the Dart ones would be correct for all of their
// completions.
commitCharactersEnabled: false,
completeFunctionCalls: false,
resolutionData: resolutionInfo,
);
});
});
}
/// 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) {
var 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;
// Disallow colons automatically triggering for switch statements
// (case, default).
case ':':
return node is! ast.SwitchStatement;
}
return true; // Any other trigger character can be handled always.
}
/// Truncates [items] to [maxCompletionCount] after sorting by fuzzy score
/// (then relevance/sortText) but always includes any items that exactly match
/// [prefix].
Iterable<_ScoredCompletionItem> _truncateResults(
List<_ScoredCompletionItem> items,
String prefix,
int maxCompletionCount,
) {
var prefixLower = prefix.toLowerCase();
bool isExactMatch(CompletionItem item) =>
(item.filterText ?? item.label).toLowerCase() == prefixLower;
// Sort the items by fuzzy score and then relevance (sortText).
items.sort(_scoreCompletionItemComparer);
// Skip the text comparisons if we don't have a prefix (plugin results, or
// just no prefix when completion was invoked).
var shouldInclude = prefixLower.isEmpty
? (int index, _ScoredCompletionItem item) => index < maxCompletionCount
: (int index, _ScoredCompletionItem item) =>
index < maxCompletionCount || isExactMatch(item.item);
return items.whereIndexed(shouldInclude);
}
/// Compares [_ScoredCompletionItem]s by their fuzzy match score and then
/// `sortText` field (which is derived from relevance).
///
/// For items with the same fuzzy score/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 _scoreCompletionItemComparer(
_ScoredCompletionItem item1,
_ScoredCompletionItem item2,
) {
// First try to sort by fuzzy score.
if (item1.score != item2.score) {
return item2.score.compareTo(item1.score);
}
// Otherwise, use sortText.
// 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.
var item1Text = item1.item.sortText ?? item1.item.label;
var item2Text = item2.item.sortText ?? item2.item.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.item.label.length.compareTo(item2.item.label.length);
}
return item1Text.compareTo(item2Text);
}
}
class CompletionRegistrations extends FeatureRegistration
with StaticRegistration<CompletionOptions> {
CompletionRegistrations(super.info);
@override
List<LspDynamicRegistration> get dynamicRegistrations {
return [
// Trigger and commit characters are specific to Dart, so register them
// separately to the others.
(
Method.textDocument_completion,
CompletionRegistrationOptions(
documentSelector: dartFiles,
triggerCharacters: dartCompletionTriggerCharacters,
allCommitCharacters:
previewCommitCharacters ? dartCompletionCommitCharacters : null,
resolveProvider: true,
completionItem:
CompletionOptionsCompletionItem(labelDetailsSupport: true),
),
),
(
Method.textDocument_completion,
CompletionRegistrationOptions(
documentSelector: nonDartCompletionTypes,
resolveProvider: true,
),
),
];
}
/// Types of documents we support completion for that are not Dart.
///
/// We use two dynamic registrations because for Dart we support trigger
/// characters but for other kinds of files we do not.
List<TextDocumentFilterWithScheme> get nonDartCompletionTypes {
var pluginTypesExcludingDart =
pluginTypes.where((filter) => filter.pattern != '**/*.dart');
return {
...pluginTypesExcludingDart,
pubspecFile,
analysisOptionsFile,
fixDataFile,
}.toList();
}
bool get previewCommitCharacters =>
clientConfiguration.global.previewCommitCharacters;
@override
CompletionOptions get staticOptions => CompletionOptions(
triggerCharacters: dartCompletionTriggerCharacters,
allCommitCharacters:
previewCommitCharacters ? dartCompletionCommitCharacters : null,
resolveProvider: true,
completionItem:
CompletionOptionsCompletionItem(labelDetailsSupport: true),
);
@override
bool get supportsDynamic => clientDynamic.completion;
}
/// A set of completion items split into ranked and unranked items.
class _CompletionResults {
/// Items that can be ranked using their relevance/sortText, returned with
/// their fuzzy match (if [targetPrefix] was provided).
final List<_ScoredCompletionItem> rankedItems;
/// Items that cannot be ranked, and should avoid being truncated.
final List<CompletionItem> unrankedItems;
/// The fuzzy filter used to score results.
final _FuzzyScoreHelper fuzzy;
final bool isIncomplete;
/// Item defaults for completion items.
///
/// Defaults are only supported on Dart server items (not plugins).
final CompletionListItemDefaults? defaults;
_CompletionResults({
this.rankedItems = const [],
this.unrankedItems = const [],
required this.fuzzy,
required this.isIncomplete,
this.defaults,
});
_CompletionResults.empty()
: this(fuzzy: _FuzzyScoreHelper.empty, isIncomplete: false);
/// An empty result set marked as incomplete because an error occurred.
_CompletionResults.emptyIncomplete()
: this(fuzzy: _FuzzyScoreHelper.empty, isIncomplete: true);
_CompletionResults.unranked(
List<CompletionItem> unrankedItems, {
required bool isIncomplete,
}) : this(
unrankedItems: unrankedItems,
fuzzy: _FuzzyScoreHelper.empty,
isIncomplete: isIncomplete,
);
/// Any prefix used to filter the results.
String get targetPrefix => fuzzy.prefix;
}
/// Helper to simplify fuzzy scoring.
///
/// Used to sort results for truncation and to filter out items that don't
/// match the characters in front of the caret to reduce the size of the
/// payload.
class _FuzzyScoreHelper {
static final empty = _FuzzyScoreHelper('');
final String prefix;
final FuzzyMatcher _matcher;
_FuzzyScoreHelper(this.prefix) : _matcher = FuzzyMatcher(prefix);
bool completionItemMatches(CompletionItem item) =>
stringMatches(item.filterText ?? item.label);
double completionItemScore(CompletionItem item) =>
_matcher.score(item.filterText ?? item.label);
bool stringMatches(String input) => _matcher.score(input) > 0;
double suggestionScore(CompletionSuggestion item) =>
_matcher.score(item.displayText ?? item.completion);
}