7ad1786e45
Change-Id: I18608b110093e87887f8e9118340e323fe829248 Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/230081 Reviewed-by: Brian Wilkerson <brianwilkerson@google.com> Commit-Queue: Brian Wilkerson <brianwilkerson@google.com>
573 lines
23 KiB
Dart
573 lines
23 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: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:analyzer/dart/analysis/results.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(LspAnalysisServer server, LspInitializationOptions options)
|
|
: suggestFromUnimportedLibraries = options.suggestFromUnimportedLibraries,
|
|
super(server);
|
|
|
|
@override
|
|
Method get handlesMessage => Method.textDocument_completion;
|
|
|
|
@override
|
|
LspJsonHandler<CompletionParams> get jsonHandler =>
|
|
CompletionParams.jsonHandler;
|
|
|
|
@override
|
|
Future<ErrorOr<CompletionList>> handle(
|
|
CompletionParams params, CancellationToken token) async {
|
|
final clientCapabilities = server.clientCapabilities;
|
|
if (clientCapabilities == null) {
|
|
// This should not happen unless a client misbehaves.
|
|
return error(ErrorCodes.ServerNotInitialized,
|
|
'Requests not before server is initilized');
|
|
}
|
|
|
|
final triggerCharacter = params.context?.triggerCharacter;
|
|
final pos = params.position;
|
|
final path = pathOfDoc(params.textDocument);
|
|
final unit = await path.mapResult(requireResolvedUnit);
|
|
|
|
final 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.mapResult(getLineInfo),
|
|
(unit) => success(unit.lineInfo),
|
|
);
|
|
final offset =
|
|
await lineInfo.mapResult((lineInfo) => toOffset(lineInfo, pos));
|
|
|
|
return offset.mapResult((offset) async {
|
|
Future<ErrorOr<CompletionList>>? serverResultsFuture;
|
|
final pathContext = server.resourceProvider.pathContext;
|
|
final fileExtension = pathContext.extension(path.result);
|
|
|
|
if (fileExtension == '.dart' && !unit.isError) {
|
|
serverResultsFuture = _getServerDartItems(
|
|
clientCapabilities,
|
|
unit.result,
|
|
offset,
|
|
triggerCharacter,
|
|
token,
|
|
);
|
|
} 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(
|
|
generator,
|
|
clientCapabilities,
|
|
path.result,
|
|
lineInfo.result,
|
|
offset,
|
|
token,
|
|
);
|
|
}
|
|
}
|
|
|
|
serverResultsFuture ??=
|
|
Future.value(success(CompletionList(isIncomplete: false, items: [])));
|
|
|
|
final pluginResultsFuture = _getPluginResults(
|
|
clientCapabilities, lineInfo.result, path.result, offset);
|
|
|
|
// Await both server + plugin results together to allow async/IO to
|
|
// overlap.
|
|
final serverAndPluginResults =
|
|
await Future.wait([serverResultsFuture, pluginResultsFuture]);
|
|
final serverResults = serverAndPluginResults[0];
|
|
final pluginResults = serverAndPluginResults[1];
|
|
|
|
if (serverResults.isError) return serverResults;
|
|
if (pluginResults.isError) return pluginResults;
|
|
|
|
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,
|
|
items: serverResults.result.items
|
|
.followedBy(pluginResults.result.items)
|
|
.toList(),
|
|
));
|
|
});
|
|
}
|
|
|
|
/// 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 = element.name;
|
|
if (librarySource != null && elementName != null) {
|
|
final declaringLibraryUri = librarySource.uri;
|
|
|
|
final key =
|
|
_createImportedSymbolKey(elementName, declaringLibraryUri);
|
|
alreadyImportedSymbols
|
|
.putIfAbsent(key, () => <String>{})
|
|
.add('${importedLibrary.librarySource.uri}');
|
|
}
|
|
}
|
|
}
|
|
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) =>
|
|
'$name/$declaringUri';
|
|
|
|
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))
|
|
.toList();
|
|
|
|
return success(CompletionList(
|
|
isIncomplete: false,
|
|
items: _pluginResultsToItems(
|
|
capabilities,
|
|
lineInfo,
|
|
offset,
|
|
pluginResults,
|
|
).toList(),
|
|
));
|
|
}
|
|
|
|
Future<ErrorOr<CompletionList>> _getServerDartItems(
|
|
LspClientCapabilities capabilities,
|
|
ResolvedUnitResult unit,
|
|
int offset,
|
|
String? triggerCharacter,
|
|
CancellationToken token,
|
|
) async {
|
|
final useSuggestionSets =
|
|
suggestFromUnimportedLibraries && capabilities.applyEdit;
|
|
|
|
var performance = OperationPerformanceImpl('<root>');
|
|
return await performance.runAsync(
|
|
'request',
|
|
(performance) async {
|
|
final completionPerformance = CompletionPerformance(
|
|
operation: performance,
|
|
path: unit.path,
|
|
content: unit.content,
|
|
offset: offset,
|
|
);
|
|
server.performanceStats.completion.add(completionPerformance);
|
|
|
|
final completionRequest = DartCompletionRequest.forResolvedUnit(
|
|
resolvedUnit: unit,
|
|
offset: offset,
|
|
dartdocDirectiveInfo: server.getDartdocDirectiveInfoFor(unit),
|
|
completionPreference: CompletionPreference.replace,
|
|
);
|
|
final target = completionRequest.target;
|
|
|
|
if (triggerCharacter != null) {
|
|
if (!_triggerCharacterValid(offset, triggerCharacter, target)) {
|
|
return success(CompletionList(isIncomplete: false, items: []));
|
|
}
|
|
}
|
|
|
|
Set<ElementKind>? includedElementKinds;
|
|
Set<String>? includedElementNames;
|
|
List<IncludedSuggestionRelevanceTag>? includedSuggestionRelevanceTags;
|
|
if (useSuggestionSets) {
|
|
includedElementKinds = <ElementKind>{};
|
|
includedElementNames = <String>{};
|
|
includedSuggestionRelevanceTags = <IncludedSuggestionRelevanceTag>[];
|
|
}
|
|
|
|
try {
|
|
var contributor = DartCompletionManager(
|
|
budget: CompletionBudget(CompletionBudget.defaultDuration),
|
|
includedElementKinds: includedElementKinds,
|
|
includedElementNames: includedElementNames,
|
|
includedSuggestionRelevanceTags: includedSuggestionRelevanceTags,
|
|
);
|
|
|
|
final serverSuggestions = await contributor.computeSuggestions(
|
|
completionRequest,
|
|
performance,
|
|
);
|
|
|
|
final insertLength = _computeInsertLength(
|
|
offset,
|
|
completionRequest.replacementOffset,
|
|
completionRequest.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.
|
|
final completeFunctionCalls = _hasExistingArgList(target.entity)
|
|
? false
|
|
: server.clientConfiguration.global.completeFunctionCalls;
|
|
|
|
final results = serverSuggestions.map(
|
|
(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(
|
|
capabilities,
|
|
unit.lineInfo,
|
|
item,
|
|
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).
|
|
// https://github.com/microsoft/vscode-languageserver-node/issues/673
|
|
includeCommitCharacters:
|
|
server.clientConfiguration.global.previewCommitCharacters,
|
|
completeFunctionCalls: completeFunctionCalls,
|
|
);
|
|
},
|
|
).toList();
|
|
|
|
// Now compute items in suggestion sets.
|
|
var includedSuggestionSets = <IncludedSuggestionSet>[];
|
|
final declarationsTracker = server.declarationsTracker;
|
|
if (declarationsTracker != null &&
|
|
includedElementKinds != null &&
|
|
includedElementNames != null &&
|
|
includedSuggestionRelevanceTags != null) {
|
|
computeIncludedSetList(
|
|
declarationsTracker,
|
|
completionRequest,
|
|
includedSuggestionSets,
|
|
includedElementNames,
|
|
);
|
|
|
|
// Build a fast lookup for imported symbols so that we can filter out
|
|
// duplicates.
|
|
final alreadyImportedSymbols = _buildLookupOfImportedSymbols(unit);
|
|
|
|
includedSuggestionSets.forEach((includedSet) {
|
|
final library = declarationsTracker.getLibrary(includedSet.id);
|
|
if (library == null) {
|
|
return;
|
|
}
|
|
|
|
// Make a fast lookup for tag relevance.
|
|
final tagBoosts = <String, int>{};
|
|
includedSuggestionRelevanceTags!
|
|
.forEach((t) => tagBoosts[t.tag] = t.relevanceBoost);
|
|
|
|
// 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);
|
|
|
|
// Collect declarations and their children.
|
|
final allDeclarations = library.declarations
|
|
.followedBy(library.declarations.expand(
|
|
(decl) => decl.children.where(shouldIncludeChild)))
|
|
.toList();
|
|
|
|
final setResults = allDeclarations
|
|
// Filter to only the kinds we should return.
|
|
.where((item) => includedElementKinds!
|
|
.contains(protocolElementKind(item.kind)))
|
|
.where((item) {
|
|
// 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.
|
|
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
|
|
: item.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}';
|
|
}).map((item) => declarationToCompletionItem(
|
|
capabilities,
|
|
unit.path,
|
|
offset,
|
|
includedSet,
|
|
library,
|
|
tagBoosts,
|
|
unit.lineInfo,
|
|
item,
|
|
completionRequest.replacementOffset,
|
|
insertLength,
|
|
completionRequest.replacementLength,
|
|
// 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).
|
|
// https://github.com/microsoft/vscode-languageserver-node/issues/673
|
|
includeCommitCharacters: server
|
|
.clientConfiguration.global.previewCommitCharacters,
|
|
completeFunctionCalls: completeFunctionCalls,
|
|
));
|
|
results.addAll(setResults);
|
|
});
|
|
}
|
|
|
|
// Perform fuzzy matching based on the identifier in front of the caret to
|
|
// reduce the size of the payload.
|
|
final fuzzyPattern = completionRequest.targetPrefix;
|
|
final fuzzyMatcher =
|
|
FuzzyMatcher(fuzzyPattern, matchStyle: MatchStyle.TEXT);
|
|
|
|
final matchingResults =
|
|
results.where((e) => fuzzyMatcher.score(e.label) > 0).toList();
|
|
|
|
completionPerformance.suggestionCount = results.length;
|
|
|
|
return success(
|
|
CompletionList(isIncomplete: false, items: matchingResults));
|
|
} on AbortCompletion {
|
|
return success(CompletionList(isIncomplete: false, items: []));
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<ErrorOr<CompletionList>> _getServerYamlItems(
|
|
YamlCompletionGenerator generator,
|
|
LspClientCapabilities capabilities,
|
|
String path,
|
|
LineInfo lineInfo,
|
|
int offset,
|
|
CancellationToken token,
|
|
) async {
|
|
final suggestions = generator.getSuggestions(path, offset);
|
|
final insertLength = _computeInsertLength(
|
|
offset,
|
|
suggestions.replacementOffset,
|
|
suggestions.replacementLength,
|
|
);
|
|
final replacementRange = toRange(
|
|
lineInfo, suggestions.replacementOffset, suggestions.replacementLength);
|
|
final insertionRange =
|
|
toRange(lineInfo, suggestions.replacementOffset, insertLength);
|
|
|
|
final completionItems = suggestions.suggestions
|
|
.map(
|
|
(item) => toCompletionItem(
|
|
capabilities,
|
|
lineInfo,
|
|
item,
|
|
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,
|
|
),
|
|
)
|
|
.toList();
|
|
return success(CompletionList(isIncomplete: false, items: 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;
|
|
}
|
|
// 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,
|
|
LineInfo lineInfo,
|
|
int offset,
|
|
List<plugin.CompletionGetSuggestionsResult> pluginResults,
|
|
) {
|
|
return pluginResults.expand((result) {
|
|
final insertLength = _computeInsertLength(
|
|
offset,
|
|
result.replacementOffset,
|
|
result.replacementLength,
|
|
);
|
|
final replacementRange =
|
|
toRange(lineInfo, result.replacementOffset, result.replacementLength);
|
|
final insertionRange =
|
|
toRange(lineInfo, result.replacementOffset, insertLength);
|
|
|
|
return result.results.map(
|
|
(item) => toCompletionItem(
|
|
capabilities,
|
|
lineInfo,
|
|
item,
|
|
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.
|
|
}
|
|
}
|