| // Copyright (c) 2015, 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. | |
| /// @docImport 'package:analysis_server/src/lsp/handlers/handler_completion.dart'; | |
| library; | |
| import 'package:analysis_server/src/computer/computer_documentation.dart'; | |
| import 'package:analysis_server/src/protocol_server.dart'; | |
| import 'package:analysis_server/src/provisional/completion/completion_core.dart'; | |
| import 'package:analysis_server/src/services/completion/dart/candidate_suggestion.dart'; | |
| import 'package:analysis_server/src/services/completion/dart/completion_state.dart'; | |
| import 'package:analysis_server/src/services/completion/dart/feature_computer.dart'; | |
| import 'package:analysis_server/src/services/completion/dart/in_scope_completion_pass.dart'; | |
| import 'package:analysis_server/src/services/completion/dart/not_imported_completion_pass.dart'; | |
| import 'package:analysis_server/src/services/completion/dart/relevance_computer.dart'; | |
| import 'package:analysis_server/src/services/completion/dart/suggestion_builder.dart'; | |
| import 'package:analysis_server/src/services/completion/dart/suggestion_collector.dart'; | |
| import 'package:analysis_server_plugin/src/utilities/selection.dart'; | |
| import 'package:analyzer/dart/analysis/features.dart'; | |
| import 'package:analyzer/dart/analysis/results.dart'; | |
| import 'package:analyzer/dart/analysis/session.dart'; | |
| import 'package:analyzer/dart/ast/ast.dart'; | |
| import 'package:analyzer/dart/ast/token.dart'; | |
| import 'package:analyzer/dart/element/element.dart'; | |
| import 'package:analyzer/dart/element/type.dart'; | |
| import 'package:analyzer/file_system/file_system.dart'; | |
| import 'package:analyzer/source/source.dart'; | |
| import 'package:analyzer/source/source_range.dart'; | |
| import 'package:analyzer/src/dart/analysis/driver_based_analysis_context.dart'; | |
| import 'package:analyzer/src/dart/analysis/file_state.dart'; | |
| import 'package:analyzer/src/dart/analysis/results.dart'; | |
| import 'package:analyzer/src/dart/analysis/session.dart'; | |
| import 'package:analyzer/src/dartdoc/dartdoc_directive_info.dart'; | |
| import 'package:analyzer/src/generated/source.dart' show SourceFactory; | |
| 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/completion_matcher.dart'; | |
| import 'package:analyzer/src/utilities/fuzzy_matcher.dart'; | |
| import 'package:analyzer_plugin/src/utilities/completion/completion_target.dart'; | |
| import 'package:analyzer_plugin/src/utilities/completion/optype.dart'; | |
| /// Class that tracks how much time budget we have left. | |
| class CompletionBudget { | |
| static const Duration defaultDuration = Duration(milliseconds: 100); | |
| final Duration _budget; | |
| final Stopwatch _timer = Stopwatch()..start(); | |
| new(this._budget); | |
| bool get isEmpty { | |
| return _timer.elapsed > _budget; | |
| } | |
| Duration get left { | |
| var result = _budget - _timer.elapsed; | |
| return result.isNegative ? Duration.zero : result; | |
| } | |
| } | |
| /// [DartCompletionManager] determines if a completion request is Dart specific | |
| /// and if so runs the two completion passes. | |
| class DartCompletionManager { | |
| /// Time budget to computing suggestions. | |
| final CompletionBudget budget; | |
| /// The listener to be notified at certain points in the process of building | |
| /// suggestions, or `null` if no notification should occur. | |
| final SuggestionListener? listener; | |
| /// Whether the generation of suggestions for imports should be skipped. This | |
| /// exists as a temporary measure that will be removed after all of the | |
| /// suggestions are being produced by the various passes. | |
| final bool skipImports; | |
| /// If specified, will be filled with suggestions and URIs from libraries | |
| /// that are not yet imported, but could be imported into the requested | |
| /// target. It is up to the client to make copies of [CompletionSuggestion]s | |
| /// with the import index property updated. | |
| final NotImportedSuggestions? notImportedSuggestions; | |
| /// Whether the number of suggestions initallly computed was greater than the | |
| /// `maxSuggestions` for a given request, and they were truncated to fit. | |
| bool isTruncated = false; | |
| new({ | |
| required this.budget, | |
| this.listener, | |
| this.skipImports = false, | |
| this.notImportedSuggestions, | |
| }); | |
| /// Return a suggestion collector containing a list of the suggestions that | |
| /// should be returned to the client. | |
| Future<SuggestionCollector> computeCandidateSuggestions({ | |
| required int maxSuggestions, | |
| required OperationPerformanceImpl performance, | |
| required DartCompletionRequest request, | |
| bool suggestOverrides = true, | |
| bool suggestUris = true, | |
| }) async { | |
| request.checkAborted(); | |
| var collector = SuggestionCollector(maxSuggestions: maxSuggestions); | |
| // Don't suggest in comments. | |
| if (request.target.isCommentText) { | |
| return collector; | |
| } | |
| try { | |
| var selection = request.unit.select(offset: request.offset, length: 0); | |
| if (selection == null) { | |
| throw AbortCompletion(); | |
| } | |
| var tokenData = TokenData.fromSelection(selection); | |
| var targetPrefix = tokenData?.prefix ?? ''; | |
| var matcher = targetPrefix.isEmpty | |
| ? NoPrefixMatcher() | |
| : FuzzyMatcher(targetPrefix); | |
| var state = CompletionState(request, selection, budget, matcher); | |
| var operations = performance.run('InScopeCompletionPass', (performance) { | |
| var pass = InScopeCompletionPass( | |
| state: state, | |
| collector: collector, | |
| skipImports: skipImports, | |
| suggestOverrides: suggestOverrides, | |
| suggestUris: suggestUris, | |
| ); | |
| pass.computeSuggestions(); | |
| state.request.collectorLocationName = collector.completionLocation; | |
| return pass.notImportedOperations; | |
| }); | |
| request.checkAborted(); | |
| if (operations.isNotEmpty && notImportedSuggestions != null) { | |
| await performance.runAsync('NotImportedCompletionPass', ( | |
| performance, | |
| ) async { | |
| await NotImportedCompletionPass( | |
| state: state, | |
| collector: collector, | |
| operations: operations, | |
| ).computeSuggestions(performance: performance); | |
| }); | |
| } | |
| } on InconsistentAnalysisException { | |
| // The state of the code being analyzed has changed, so results are likely | |
| // to be inconsistent. Just abort the operation. | |
| throw AbortCompletion(); | |
| } | |
| return collector; | |
| } | |
| /// Return a suggestion collector containing a finalized list of suggestions. | |
| Future<SuggestionCollector> computeFinalizedCandidateSuggestions({ | |
| required int maxSuggestions, | |
| required OperationPerformanceImpl performance, | |
| required DartCompletionRequest request, | |
| }) async { | |
| // TODO(FMorschel): move deduplication logic from `suggestFromCandidates` | |
| // that uses a map with `CompletionItem` as a key. Probably best if we | |
| // make the deduplication over at `CandidateSuggestion` level. | |
| var collector = await computeCandidateSuggestions( | |
| maxSuggestions: maxSuggestions, | |
| performance: performance, | |
| request: request, | |
| ); | |
| var notImportedSuggestions = this.notImportedSuggestions; | |
| if (notImportedSuggestions != null && collector.isIncomplete) { | |
| notImportedSuggestions.isIncomplete = true; | |
| } | |
| // Compute relevance, sort and truncate list. | |
| isTruncated = collector.suggestions.length > maxSuggestions; | |
| collector.finalize( | |
| RelevanceComputer(request, listener, targetPrefix: request.targetPrefix), | |
| ); | |
| return collector; | |
| } | |
| Future<List<CompletionSuggestionBuilder>> computeSuggestions( | |
| DartCompletionRequest request, | |
| OperationPerformanceImpl performance, { | |
| bool enableOverrideContributor = true, | |
| bool enableUriContributor = true, | |
| required int maxSuggestions, | |
| required bool useFilter, | |
| }) async { | |
| request.checkAborted(); | |
| var pathContext = request.resourceProvider.pathContext; | |
| if (!file_paths.isDart(pathContext, request.path)) { | |
| return const []; | |
| } | |
| // Don't suggest in comments. | |
| if (request.target.isCommentText) { | |
| return const []; | |
| } | |
| var collector = await computeCandidateSuggestions( | |
| maxSuggestions: maxSuggestions, | |
| performance: performance, | |
| request: request, | |
| suggestOverrides: enableOverrideContributor, | |
| suggestUris: enableUriContributor, | |
| ); | |
| var builder = SuggestionBuilder( | |
| request, | |
| useFilter: useFilter, | |
| listener: listener, | |
| ); | |
| await builder.suggestFromCandidates( | |
| collector.suggestions, | |
| collector.preferConstants, | |
| collector.completionLocation, | |
| ); | |
| var notImportedSuggestions = this.notImportedSuggestions; | |
| if (notImportedSuggestions != null && collector.isIncomplete) { | |
| notImportedSuggestions.isIncomplete = true; | |
| } | |
| return builder.suggestions.toList(); | |
| } | |
| } | |
| /// The information about a requested list of completions within a Dart file. | |
| class DartCompletionRequest { | |
| /// The analysis session that produced the elements of the request. | |
| final AnalysisSessionImpl analysisSession; | |
| final CompletionPreference completionPreference; | |
| /// The content of the file in which completion is requested. | |
| final String content; | |
| /// Return the type imposed on the target's `containingNode` based on its | |
| /// context, or `null` if the context does not impose any type. | |
| final DartType? contextType; | |
| /// Return the object used to compute documentation for elements and resolve | |
| /// macros in Dartdoc comments. | |
| final DartDocumentationComputer documentationComputer; | |
| /// Return the object used to compute the values of the features used to | |
| /// compute relevance scores for suggestions. | |
| final FeatureComputer featureComputer; | |
| /// The library element of the file in which completion is requested. | |
| final LibraryElement libraryElement; | |
| /// The library fragment of the file in which completion is requested. | |
| final LibraryFragment libraryFragment; | |
| /// Return the offset within the source at which the completion is being | |
| /// requested. | |
| final int offset; | |
| /// The [OpType] which describes which types of suggestions would fit the | |
| /// request. | |
| final OpType opType; | |
| /// The file where completion is requested. | |
| final FileState fileState; | |
| /// The absolute path of the file where completion is requested. | |
| final String path; | |
| /// The source range that represents the region of text that should be | |
| /// replaced when a suggestion is selected. | |
| final SourceRange replacementRange; | |
| /// Return the source in which the completion is being requested. | |
| final Source source; | |
| /// The completion target. | |
| /// | |
| /// This determines what part of the parse tree will receive the newly | |
| /// inserted text. At a minimum, all declarations in the completion scope in | |
| /// `target.unit` will be resolved if they can be resolved. | |
| final CompletionTarget target; | |
| /// The compilation unit in which completion is being requested. | |
| final CompilationUnit unit; | |
| /// The location name from [SuggestionCollector]. | |
| String? collectorLocationName; | |
| bool _aborted = false; | |
| /// Return `true` if the completion is occurring in a constant context. | |
| late final bool inConstantContext = () { | |
| var entity = target.entity; | |
| return entity is Expression && entity.inConstantContext; | |
| }(); | |
| factory({ | |
| required AnalysisSession analysisSession, | |
| required FileState fileState, | |
| required String filePath, | |
| required String fileContent, | |
| required LibraryFragment libraryFragment, | |
| required int offset, | |
| required CompilationUnit unit, | |
| DartdocDirectiveInfo? dartdocDirectiveInfo, | |
| CompletionPreference completionPreference = CompletionPreference.insert, | |
| }) { | |
| var target = CompletionTarget.forOffset(unit, offset); | |
| var libraryElement = libraryFragment.element; | |
| var featureComputer = FeatureComputer( | |
| libraryElement.typeSystem, | |
| libraryElement.typeProvider, | |
| ); | |
| var contextType = featureComputer.computeContextType( | |
| target.containingNode, | |
| offset, | |
| ); | |
| var opType = OpType.forCompletion(target, offset); | |
| if (contextType is VoidType) { | |
| opType.includeVoidReturnSuggestions = true; | |
| } | |
| return DartCompletionRequest._( | |
| analysisSession: analysisSession as AnalysisSessionImpl, | |
| completionPreference: completionPreference, | |
| content: fileContent, | |
| contextType: contextType, | |
| documentationComputer: DartDocumentationComputer( | |
| dartdocDirectiveInfo ?? DartdocDirectiveInfo(), | |
| ), | |
| featureComputer: featureComputer, | |
| libraryElement: libraryElement, | |
| libraryFragment: libraryFragment, | |
| offset: offset, | |
| opType: opType, | |
| fileState: fileState, | |
| path: filePath, | |
| replacementRange: target.computeReplacementRange( | |
| offset, | |
| isDotShorthandEnabled: libraryElement.featureSet.isEnabled( | |
| .dot_shorthands, | |
| ), | |
| ), | |
| source: libraryFragment.source, | |
| target: target, | |
| unit: unit, | |
| ); | |
| } | |
| factory forResolvedUnit({ | |
| required ResolvedUnitResult resolvedUnit, | |
| required int offset, | |
| DartdocDirectiveInfo? dartdocDirectiveInfo, | |
| CompletionPreference completionPreference = CompletionPreference.insert, | |
| }) { | |
| resolvedUnit as ResolvedUnitResultImpl; | |
| return DartCompletionRequest( | |
| analysisSession: resolvedUnit.session, | |
| fileState: resolvedUnit.fileState, | |
| filePath: resolvedUnit.path, | |
| fileContent: resolvedUnit.content, | |
| libraryFragment: resolvedUnit.libraryFragment, | |
| offset: offset, | |
| unit: resolvedUnit.unit, | |
| dartdocDirectiveInfo: dartdocDirectiveInfo, | |
| completionPreference: completionPreference, | |
| ); | |
| } | |
| new _({ | |
| required this.analysisSession, | |
| required this.completionPreference, | |
| required this.content, | |
| required this.contextType, | |
| required this.documentationComputer, | |
| required this.featureComputer, | |
| required this.libraryElement, | |
| required this.libraryFragment, | |
| required this.offset, | |
| required this.opType, | |
| required this.fileState, | |
| required this.path, | |
| required this.replacementRange, | |
| required this.source, | |
| required this.target, | |
| required this.unit, | |
| }); | |
| DriverBasedAnalysisContext get analysisContext { | |
| var analysisContext = analysisSession.analysisContext; | |
| return analysisContext as DriverBasedAnalysisContext; | |
| } | |
| /// Return the feature set that was used to analyze the compilation unit in | |
| /// which suggestions are being made. | |
| FeatureSet get featureSet => libraryElement.featureSet; | |
| /// Return `true` if free standing identifiers should be suggested | |
| bool get includeIdentifiers { | |
| return opType.includeIdentifiers; | |
| } | |
| /// Used by completion on dot-shorthand nodes when the feature is disabled so | |
| /// we can determine if we should suggest unnamed constructors as `new`. | |
| bool get isReplacingKeywordOrIdentifier { | |
| var entity = target.entity; | |
| if (entity is Token) { | |
| return entity.isKeywordOrIdentifier && | |
| replacementRange.offset >= entity.offset || | |
| replacementRange.end >= entity.offset; | |
| } else if (entity case SimpleIdentifier(isSynthetic: false)) { | |
| return replacementRange.offset >= entity.offset || | |
| replacementRange.end >= entity.offset; | |
| } | |
| return false; | |
| } | |
| /// Answer the [DartType] for Object in dart:core | |
| InterfaceType get objectType => libraryElement.typeProvider.objectType; | |
| /// 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). | |
| /// This will be different than the [replacementOffset] - [offset] | |
| /// if the [offset] is in the middle of an existing identifier. | |
| int get replacementLength => replacementRange.length; | |
| /// 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. | |
| int get replacementOffset => replacementRange.offset; | |
| /// Return the resource provider associated with this request. | |
| ResourceProvider get resourceProvider => analysisSession.resourceProvider; | |
| /// Return the [SourceFactory] of the request. | |
| SourceFactory get sourceFactory { | |
| return analysisContext.driver.sourceFactory; | |
| } | |
| /// Return prefix that already exists in the document for [target] or empty | |
| /// string if unavailable. This can be used to filter the completion list to | |
| /// items that already match the text to the left of the caret. | |
| String get targetPrefix { | |
| var entity = target.entity; | |
| if (entity is Token) { | |
| var prev = entity.previous; | |
| if (prev != null && prev.end == offset && prev.isKeywordOrIdentifier) { | |
| return prev.lexeme; | |
| } | |
| } | |
| if (entity is Token && | |
| entity.type == TokenType.STRING && | |
| entity.offset < offset && | |
| offset < entity.end) { | |
| var uriNode = target.containingNode; | |
| if (uriNode is SimpleStringLiteral && uriNode.literal == entity) { | |
| var directive = uriNode.parent; | |
| if (directive is UriBasedDirective && | |
| directive.uri == uriNode && | |
| offset >= uriNode.contentsOffset) { | |
| return uriNode.value.substring(0, offset - uriNode.contentsOffset); | |
| } | |
| } | |
| } | |
| // TODO(scheglov): Can we make it better? | |
| String fromToken(Token token) { | |
| var lexeme = token.lexeme; | |
| if (offset >= token.offset && offset < token.end) { | |
| return lexeme.substring(0, offset - token.offset); | |
| } else if (offset == token.end) { | |
| return lexeme; | |
| } | |
| return ''; | |
| } | |
| if (entity is Token) { | |
| if (entity.end == offset && entity.isKeywordOrIdentifier) { | |
| return fromToken(entity); | |
| } | |
| } | |
| if (entity is DeclaredVariablePattern && entity.name.offset <= offset) { | |
| return fromToken(entity.name); | |
| } | |
| while (entity is AstNode) { | |
| if (entity is SimpleIdentifier) { | |
| return fromToken(entity.token); | |
| } | |
| var children = entity.childEntities; | |
| entity = children.isEmpty ? null : children.first; | |
| if (entity is Token) { | |
| return fromToken(entity); | |
| } | |
| } | |
| return ''; | |
| } | |
| /// Abort the current completion request. | |
| void abort() { | |
| _aborted = true; | |
| } | |
| /// Throw [AbortCompletion] if the completion request has been aborted. | |
| void checkAborted() { | |
| if (_aborted) { | |
| throw AbortCompletion(); | |
| } | |
| } | |
| } | |
| /// Information provided by [CompletionHandler]s in addition to suggestions. | |
| class NotImportedSuggestions { | |
| /// This flag is set to `true` if the contributor decided to stop before it | |
| /// processed all available libraries, e.g. we ran out of budget. | |
| bool isIncomplete = false; | |
| } | |
| /// Information about the token containing the selection. | |
| class TokenData { | |
| /// The token containing the offset. | |
| /// | |
| /// The token can be any token, including a comment token. | |
| final Token token; | |
| /// The prefix before the selection offset. | |
| /// | |
| /// This will be an empty string if the token isn't either an identifier or | |
| /// keyword, or if the selection offset is at the beginning of the token. | |
| final String prefix; | |
| new _(this.token, this.prefix); | |
| /// Returns token data representing the token containing the offset of the | |
| /// [selection], or `null` if the offset isn't within any token. | |
| static TokenData? fromSelection(Selection selection) { | |
| var coveringNode = selection.coveringNode; | |
| var selectionOffset = selection.offset; | |
| // Start at the last token in the covering node and walk backward in the | |
| // token stream until we've found the left-most token whose offset is before | |
| // the `selectionOffset`. | |
| var currentToken = coveringNode.endToken; | |
| while ((currentToken.isSynthetic || | |
| currentToken.offset > selectionOffset || | |
| (currentToken.offset == selectionOffset && | |
| !currentToken.isKeywordOrIdentifier)) && | |
| !currentToken.isEof) { | |
| if (currentToken.previous case var previous?) { | |
| currentToken = previous; | |
| } else { | |
| return null; | |
| } | |
| } | |
| if (currentToken.isEof) { | |
| return null; | |
| } | |
| if (selectionOffset > currentToken.end) { | |
| // The selection is between two tokens. Check to see whether it's inside a | |
| // comment token. | |
| Token? commentToken = currentToken.next!.precedingComments; | |
| while (commentToken != null) { | |
| if (selectionOffset >= commentToken.offset && | |
| selectionOffset <= commentToken.end) { | |
| return TokenData._(commentToken, ''); | |
| } | |
| commentToken = commentToken.next; | |
| } | |
| return null; | |
| } | |
| if (currentToken.isKeywordOrIdentifier) { | |
| var offsetInToken = selectionOffset - currentToken.offset; | |
| var prefix = currentToken.lexeme.substring(0, offsetInToken); | |
| return TokenData._(currentToken, prefix); | |
| } else if (currentToken.type == TokenType.STRING) { | |
| // Compute a prefix inside string literals to support completion of URIs | |
| // in directives. | |
| var lexeme = currentToken.lexeme; | |
| var startOfContent = 1; | |
| if (lexeme.startsWith("r'''") || lexeme.startsWith('r"""')) { | |
| startOfContent = 4; | |
| } else if (lexeme.startsWith("r'") || lexeme.startsWith('r"')) { | |
| startOfContent = 2; | |
| } else if (lexeme.startsWith("'''") || lexeme.startsWith('"""')) { | |
| startOfContent = 3; | |
| } | |
| var offsetInToken = selectionOffset - currentToken.offset; | |
| if (offsetInToken < startOfContent) { | |
| // The cursor is inside the opening quote sequence. | |
| return TokenData._(currentToken, ''); | |
| } | |
| var prefix = currentToken.lexeme.substring(startOfContent, offsetInToken); | |
| return TokenData._(currentToken, prefix); | |
| } | |
| return TokenData._(currentToken, ''); | |
| } | |
| } |