79327c9f05
Change-Id: Ia79567d248f2c91290bfdf8204ea7e9f3dc85fa4 Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/206668 Reviewed-by: Brian Wilkerson <brianwilkerson@google.com> Commit-Queue: Konstantin Shcheglov <scheglov@google.com>
521 lines
19 KiB
Dart
521 lines
19 KiB
Dart
// Copyright (c) 2014, 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/protocol/protocol.dart';
|
|
import 'package:analysis_server/protocol/protocol_constants.dart';
|
|
import 'package:analysis_server/protocol/protocol_generated.dart';
|
|
import 'package:analysis_server/src/analysis_server.dart';
|
|
import 'package:analysis_server/src/collections.dart';
|
|
import 'package:analysis_server/src/domain_abstract.dart';
|
|
import 'package:analysis_server/src/domains/completion/available_suggestions.dart';
|
|
import 'package:analysis_server/src/plugin/plugin_manager.dart';
|
|
import 'package:analysis_server/src/provisional/completion/completion_core.dart';
|
|
import 'package:analysis_server/src/services/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/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/analysis/session.dart';
|
|
import 'package:analyzer/exception/exception.dart';
|
|
import 'package:analyzer/src/generated/engine.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.dart' as plugin;
|
|
import 'package:analyzer_plugin/protocol/protocol_common.dart';
|
|
import 'package:analyzer_plugin/protocol/protocol_generated.dart' as plugin;
|
|
import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart';
|
|
|
|
/// Instances of the class [CompletionDomainHandler] implement a
|
|
/// [RequestHandler] that handles requests in the completion domain.
|
|
class CompletionDomainHandler extends AbstractRequestHandler {
|
|
/// The maximum number of performance measurements to keep.
|
|
static const int performanceListMaxLength = 50;
|
|
|
|
/// The completion services that the client is currently subscribed.
|
|
final Set<CompletionService> subscriptions = <CompletionService>{};
|
|
|
|
/// The next completion response id.
|
|
int _nextCompletionId = 0;
|
|
|
|
/// Code completion performance for the last completion operation.
|
|
CompletionPerformance? performance;
|
|
|
|
/// A list of code completion performance measurements for the latest
|
|
/// completion operation up to [performanceListMaxLength] measurements.
|
|
final RecentBuffer<CompletionPerformance> performanceList =
|
|
RecentBuffer<CompletionPerformance>(performanceListMaxLength);
|
|
|
|
/// The current request being processed or `null` if none.
|
|
CompletionRequestImpl? _currentRequest;
|
|
|
|
/// The identifiers of the latest `getSuggestionDetails` request.
|
|
/// We use it to abort previous requests.
|
|
int _latestGetSuggestionDetailsId = 0;
|
|
|
|
/// Initialize a new request handler for the given [server].
|
|
CompletionDomainHandler(AnalysisServer server) : super(server);
|
|
|
|
/// Compute completion results for the given request and append them to the
|
|
/// stream. Clients should not call this method directly as it is
|
|
/// automatically called when a client listens to the stream returned by
|
|
/// [results]. Subclasses should override this method, append at least one
|
|
/// result to the [controller], and close the controller stream once complete.
|
|
Future<CompletionResult> computeSuggestions(
|
|
OperationPerformanceImpl perf,
|
|
CompletionRequestImpl request,
|
|
CompletionGetSuggestionsParams params,
|
|
Set<ElementKind>? includedElementKinds,
|
|
Set<String>? includedElementNames,
|
|
List<IncludedSuggestionRelevanceTag>? includedSuggestionRelevanceTags,
|
|
) async {
|
|
//
|
|
// Allow plugins to start computing fixes.
|
|
//
|
|
Map<PluginInfo, Future<plugin.Response>>? pluginFutures;
|
|
plugin.CompletionGetSuggestionsParams? requestParams;
|
|
var file = params.file;
|
|
var offset = params.offset;
|
|
var driver = server.getAnalysisDriver(file);
|
|
if (driver != null) {
|
|
requestParams = plugin.CompletionGetSuggestionsParams(file, offset);
|
|
pluginFutures = server.pluginManager.broadcastRequest(
|
|
requestParams,
|
|
contextRoot: driver.analysisContext!.contextRoot,
|
|
);
|
|
}
|
|
//
|
|
// Compute completions generated by server.
|
|
//
|
|
var suggestions = <CompletionSuggestion>[];
|
|
const COMPUTE_SUGGESTIONS_TAG = 'computeSuggestions';
|
|
await perf.runAsync(COMPUTE_SUGGESTIONS_TAG, (perf) async {
|
|
var manager = DartCompletionManager(
|
|
dartdocDirectiveInfo: server.getDartdocDirectiveInfoFor(
|
|
request.result,
|
|
),
|
|
includedElementKinds: includedElementKinds,
|
|
includedElementNames: includedElementNames,
|
|
includedSuggestionRelevanceTags: includedSuggestionRelevanceTags,
|
|
);
|
|
|
|
var contributorTag = 'computeSuggestions - ${manager.runtimeType}';
|
|
await perf.runAsync(contributorTag, (performance) async {
|
|
try {
|
|
suggestions.addAll(
|
|
await manager.computeSuggestions(performance, request,
|
|
documentationCache:
|
|
server.getDocumentationCacheFor(request.result)),
|
|
);
|
|
} on AbortCompletion {
|
|
suggestions.clear();
|
|
}
|
|
});
|
|
});
|
|
// TODO (danrubel) if request is obsolete (processAnalysisRequest returns
|
|
// false) then send empty results
|
|
|
|
//
|
|
// Add the completions produced by plugins to the server-generated list.
|
|
//
|
|
if (pluginFutures != null) {
|
|
var responses = await waitForResponses(pluginFutures,
|
|
requestParameters: requestParams, timeout: 100);
|
|
for (var response in responses) {
|
|
var result =
|
|
plugin.CompletionGetSuggestionsResult.fromResponse(response);
|
|
if (result.results.isNotEmpty) {
|
|
if (suggestions.isEmpty) {
|
|
request.replacementOffset = result.replacementOffset;
|
|
request.replacementLength = result.replacementLength;
|
|
} else if (request.replacementOffset != result.replacementOffset &&
|
|
request.replacementLength != result.replacementLength) {
|
|
server.instrumentationService
|
|
.logError('Plugin completion-results dropped due to conflicting'
|
|
' replacement offset/length: ${result.toJson()}');
|
|
continue;
|
|
}
|
|
suggestions.addAll(result.results);
|
|
}
|
|
}
|
|
}
|
|
//
|
|
// Return the result.
|
|
//
|
|
return CompletionResult(
|
|
request.replacementOffset, request.replacementLength, suggestions);
|
|
}
|
|
|
|
/// Return the suggestions that should be presented in the YAML [file] at the
|
|
/// given [offset].
|
|
YamlCompletionResults computeYamlSuggestions(String file, int offset) {
|
|
var provider = server.resourceProvider;
|
|
var pathContext = provider.pathContext;
|
|
if (file_paths.isAnalysisOptionsYaml(pathContext, file)) {
|
|
var generator = AnalysisOptionsGenerator(provider);
|
|
return generator.getSuggestions(file, offset);
|
|
} else if (file_paths.isFixDataYaml(pathContext, file)) {
|
|
var generator = FixDataGenerator(provider);
|
|
return generator.getSuggestions(file, offset);
|
|
} else if (file_paths.isPubspecYaml(pathContext, file)) {
|
|
var generator = PubspecGenerator(provider, server.pubPackageService);
|
|
return generator.getSuggestions(file, offset);
|
|
}
|
|
return const YamlCompletionResults.empty();
|
|
}
|
|
|
|
/// Process a `completion.getSuggestionDetails` request.
|
|
void getSuggestionDetails(Request request) async {
|
|
var params = CompletionGetSuggestionDetailsParams.fromRequest(request);
|
|
|
|
var file = params.file;
|
|
if (server.sendResponseErrorIfInvalidFilePath(request, file)) {
|
|
return;
|
|
}
|
|
|
|
var libraryId = params.id;
|
|
var declarationsTracker = server.declarationsTracker;
|
|
if (declarationsTracker == null) {
|
|
server.sendResponse(Response.unsupportedFeature(
|
|
request.id, 'Completion is not enabled.'));
|
|
return;
|
|
}
|
|
var library = declarationsTracker.getLibrary(libraryId);
|
|
if (library == null) {
|
|
server.sendResponse(Response.invalidParameter(
|
|
request,
|
|
'libraryId',
|
|
'No such library: $libraryId',
|
|
));
|
|
return;
|
|
}
|
|
|
|
// The label might be `MyEnum.myValue`, but we import only `MyEnum`.
|
|
var requestedName = params.label;
|
|
if (requestedName.contains('.')) {
|
|
requestedName = requestedName.substring(
|
|
0,
|
|
requestedName.indexOf('.'),
|
|
);
|
|
}
|
|
|
|
const timeout = Duration(milliseconds: 1000);
|
|
var timer = Stopwatch()..start();
|
|
var id = ++_latestGetSuggestionDetailsId;
|
|
while (id == _latestGetSuggestionDetailsId && timer.elapsed < timeout) {
|
|
try {
|
|
var analysisDriver = server.getAnalysisDriver(file);
|
|
if (analysisDriver == null) {
|
|
server.sendResponse(Response.fileNotAnalyzed(request, 'libraryId'));
|
|
return;
|
|
}
|
|
var session = analysisDriver.currentSession;
|
|
|
|
var completion = params.label;
|
|
var builder = ChangeBuilder(session: session);
|
|
await builder.addDartFileEdit(file, (builder) {
|
|
var result = builder.importLibraryElement(library.uri);
|
|
if (result.prefix != null) {
|
|
completion = '${result.prefix}.$completion';
|
|
}
|
|
});
|
|
|
|
server.sendResponse(
|
|
CompletionGetSuggestionDetailsResult(
|
|
completion,
|
|
change: builder.sourceChange,
|
|
).toResponse(request.id),
|
|
);
|
|
return;
|
|
} on InconsistentAnalysisException {
|
|
// Loop around to try again.
|
|
}
|
|
}
|
|
|
|
// Timeout or abort, send the empty response.
|
|
server.sendResponse(
|
|
CompletionGetSuggestionDetailsResult('').toResponse(request.id),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Response? handleRequest(Request request) {
|
|
if (!server.options.featureSet.completion) {
|
|
return Response.invalidParameter(
|
|
request,
|
|
'request',
|
|
'The completion feature is not enabled',
|
|
);
|
|
}
|
|
|
|
return runZonedGuarded<Response?>(() {
|
|
var requestName = request.method;
|
|
|
|
if (requestName == COMPLETION_REQUEST_GET_SUGGESTION_DETAILS) {
|
|
getSuggestionDetails(request);
|
|
return Response.DELAYED_RESPONSE;
|
|
} else if (requestName == COMPLETION_REQUEST_GET_SUGGESTIONS) {
|
|
processRequest(request);
|
|
return Response.DELAYED_RESPONSE;
|
|
} else if (requestName == COMPLETION_REQUEST_SET_SUBSCRIPTIONS) {
|
|
return setSubscriptions(request);
|
|
}
|
|
return null;
|
|
}, (exception, stackTrace) {
|
|
AnalysisEngine.instance.instrumentationService.logException(
|
|
CaughtException.withMessage(
|
|
'Failed to handle completion domain request: ${request.method}',
|
|
exception,
|
|
stackTrace));
|
|
});
|
|
}
|
|
|
|
void ifMatchesRequestClear(CompletionRequest completionRequest) {
|
|
if (_currentRequest == completionRequest) {
|
|
_currentRequest = null;
|
|
}
|
|
}
|
|
|
|
/// Process a `completion.getSuggestions` request.
|
|
Future<void> processRequest(Request request) async {
|
|
final performance = this.performance = CompletionPerformance();
|
|
|
|
await performance.runRequestOperation((perf) async {
|
|
// extract and validate params
|
|
var params = CompletionGetSuggestionsParams.fromRequest(request);
|
|
var file = params.file;
|
|
var offset = params.offset;
|
|
|
|
if (server.sendResponseErrorIfInvalidFilePath(request, file)) {
|
|
return;
|
|
}
|
|
if (file.endsWith('.yaml')) {
|
|
// Return the response without results.
|
|
var completionId = (_nextCompletionId++).toString();
|
|
server.sendResponse(CompletionGetSuggestionsResult(completionId)
|
|
.toResponse(request.id));
|
|
// Send a notification with results.
|
|
final suggestions = computeYamlSuggestions(file, offset);
|
|
sendCompletionNotification(
|
|
completionId,
|
|
suggestions.replacementOffset,
|
|
suggestions.replacementLength,
|
|
suggestions.suggestions,
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
);
|
|
return;
|
|
} else if (!file.endsWith('.dart')) {
|
|
// Return the response without results.
|
|
var completionId = (_nextCompletionId++).toString();
|
|
server.sendResponse(CompletionGetSuggestionsResult(completionId)
|
|
.toResponse(request.id));
|
|
// Send a notification with results.
|
|
sendCompletionNotification(
|
|
completionId, offset, 0, [], null, null, null, null);
|
|
return;
|
|
}
|
|
|
|
var resolvedUnit = await server.getResolvedUnit(file);
|
|
if (resolvedUnit == null) {
|
|
server.sendResponse(Response.fileNotAnalyzed(request, 'params.offset'));
|
|
return;
|
|
}
|
|
server.requestStatistics?.addItemTimeNow(request, 'resolvedUnit');
|
|
if (resolvedUnit.state == ResultState.VALID) {
|
|
if (offset < 0 || offset > resolvedUnit.content.length) {
|
|
server.sendResponse(Response.invalidParameter(
|
|
request,
|
|
'params.offset',
|
|
'Expected offset between 0 and source length inclusive,'
|
|
' but found $offset'));
|
|
return;
|
|
}
|
|
|
|
recordRequest(performance, file, resolvedUnit.content, offset);
|
|
}
|
|
var declarationsTracker = server.declarationsTracker;
|
|
if (declarationsTracker == null) {
|
|
server.sendResponse(Response.unsupportedFeature(
|
|
request.id, 'Completion is not enabled.'));
|
|
return;
|
|
}
|
|
var completionRequest =
|
|
CompletionRequestImpl(resolvedUnit, offset, performance);
|
|
|
|
var completionId = (_nextCompletionId++).toString();
|
|
|
|
setNewRequest(completionRequest);
|
|
|
|
// initial response without results
|
|
server.sendResponse(
|
|
CompletionGetSuggestionsResult(completionId).toResponse(request.id));
|
|
|
|
// If the client opted into using available suggestion sets,
|
|
// create the kinds set, so signal the completion manager about opt-in.
|
|
Set<ElementKind>? includedElementKinds;
|
|
Set<String>? includedElementNames;
|
|
List<IncludedSuggestionRelevanceTag>? includedSuggestionRelevanceTags;
|
|
if (subscriptions.contains(CompletionService.AVAILABLE_SUGGESTION_SETS)) {
|
|
includedElementKinds = <ElementKind>{};
|
|
includedElementNames = <String>{};
|
|
includedSuggestionRelevanceTags = <IncludedSuggestionRelevanceTag>[];
|
|
}
|
|
|
|
// Compute suggestions in the background
|
|
try {
|
|
var result = await computeSuggestions(
|
|
perf,
|
|
completionRequest,
|
|
params,
|
|
includedElementKinds,
|
|
includedElementNames,
|
|
includedSuggestionRelevanceTags,
|
|
);
|
|
String? libraryFile;
|
|
var includedSuggestionSets = <IncludedSuggestionSet>[];
|
|
if (includedElementKinds != null && includedElementNames != null) {
|
|
libraryFile = resolvedUnit.libraryElement.source.fullName;
|
|
server.sendNotification(
|
|
createExistingImportsNotification(resolvedUnit),
|
|
);
|
|
computeIncludedSetList(
|
|
declarationsTracker,
|
|
resolvedUnit,
|
|
includedSuggestionSets,
|
|
includedElementNames,
|
|
);
|
|
}
|
|
|
|
const SEND_NOTIFICATION_TAG = 'send notification';
|
|
perf.run(SEND_NOTIFICATION_TAG, (_) {
|
|
sendCompletionNotification(
|
|
completionId,
|
|
result.replacementOffset,
|
|
result.replacementLength,
|
|
result.suggestions,
|
|
libraryFile,
|
|
includedSuggestionSets,
|
|
includedElementKinds?.toList(),
|
|
includedSuggestionRelevanceTags,
|
|
);
|
|
});
|
|
|
|
performance.suggestionCount = result.suggestions.length;
|
|
} finally {
|
|
ifMatchesRequestClear(completionRequest);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// If tracking code completion performance over time, then
|
|
/// record addition information about the request in the performance record.
|
|
void recordRequest(CompletionPerformance performance, String path,
|
|
String content, int offset) {
|
|
performance.path = path;
|
|
if (performanceListMaxLength == 0) {
|
|
return;
|
|
}
|
|
performance.setContentsAndOffset(content, offset);
|
|
performanceList.add(performance);
|
|
}
|
|
|
|
/// Send completion notification results.
|
|
void sendCompletionNotification(
|
|
String completionId,
|
|
int replacementOffset,
|
|
int replacementLength,
|
|
List<CompletionSuggestion> results,
|
|
String? libraryFile,
|
|
List<IncludedSuggestionSet>? includedSuggestionSets,
|
|
List<ElementKind>? includedElementKinds,
|
|
List<IncludedSuggestionRelevanceTag>? includedSuggestionRelevanceTags,
|
|
) {
|
|
server.sendNotification(
|
|
CompletionResultsParams(
|
|
completionId,
|
|
replacementOffset,
|
|
replacementLength,
|
|
results,
|
|
true,
|
|
libraryFile: libraryFile,
|
|
includedSuggestionSets: includedSuggestionSets,
|
|
includedElementKinds: includedElementKinds,
|
|
includedSuggestionRelevanceTags: includedSuggestionRelevanceTags,
|
|
).toNotification(),
|
|
);
|
|
}
|
|
|
|
void setNewRequest(CompletionRequestImpl completionRequest) {
|
|
_abortCurrentRequest();
|
|
_currentRequest = completionRequest;
|
|
}
|
|
|
|
/// Implement the 'completion.setSubscriptions' request.
|
|
Response setSubscriptions(Request request) {
|
|
var params = CompletionSetSubscriptionsParams.fromRequest(request);
|
|
|
|
subscriptions.clear();
|
|
subscriptions.addAll(params.subscriptions);
|
|
|
|
var data = server.declarationsTrackerData;
|
|
if (data != null) {
|
|
if (subscriptions.contains(CompletionService.AVAILABLE_SUGGESTION_SETS)) {
|
|
var soFarLibraries = data.startListening((change) {
|
|
server.sendNotification(
|
|
createCompletionAvailableSuggestionsNotification(
|
|
change.changed,
|
|
change.removed,
|
|
),
|
|
);
|
|
});
|
|
server.sendNotification(
|
|
createCompletionAvailableSuggestionsNotification(
|
|
soFarLibraries,
|
|
[],
|
|
),
|
|
);
|
|
} else {
|
|
data.stopListening();
|
|
}
|
|
}
|
|
return CompletionSetSubscriptionsResult().toResponse(request.id);
|
|
}
|
|
|
|
/// Abort the current completion request, if any.
|
|
void _abortCurrentRequest() {
|
|
var currentRequest = _currentRequest;
|
|
if (currentRequest != null) {
|
|
currentRequest.abort();
|
|
_currentRequest = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The result of computing suggestions for code completion.
|
|
class CompletionResult {
|
|
/// The length of the text to be replaced if the remainder of the identifier
|
|
/// containing the cursor is to be replaced when the suggestion is applied
|
|
/// (that is, the number of characters in the existing identifier).
|
|
final int replacementLength;
|
|
|
|
/// The offset of the start of the text to be replaced. This will be different
|
|
/// than the offset used to request the completion suggestions if there was a
|
|
/// portion of an identifier before the original offset. In particular, the
|
|
/// replacementOffset will be the offset of the beginning of said identifier.
|
|
final int replacementOffset;
|
|
|
|
/// The suggested completions.
|
|
final List<CompletionSuggestion> suggestions;
|
|
|
|
CompletionResult(
|
|
this.replacementOffset, this.replacementLength, this.suggestions);
|
|
}
|