blob: 40356378c9ba7b8006242a0480dc27f7493fe6b1 [file] [log] [blame]
// Copyright (c) 2024, 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 'package:analysis_server/lsp_protocol/protocol.dart' as lsp;
import 'package:analysis_server/plugin/protocol/protocol_dart.dart';
import 'package:analysis_server/src/collections.dart';
import 'package:analysis_server/src/computer/computer_documentation.dart';
import 'package:analysis_server/src/lsp/client_capabilities.dart';
import 'package:analysis_server/src/lsp/dartdoc.dart';
import 'package:analysis_server/src/lsp/mapping.dart';
import 'package:analysis_server/src/protocol_server.dart' as server;
import 'package:analysis_server/src/services/completion/dart/candidate_suggestion.dart';
import 'package:analysis_server/src/services/completion/dart/completion_manager.dart';
import 'package:analysis_server/src/services/completion/dart/utilities.dart';
import 'package:analysis_server/src/utilities/extensions/ast.dart';
import 'package:analysis_server/src/utilities/extensions/element.dart';
import 'package:analysis_server/src/utilities/extensions/string.dart';
import 'package:analyzer/dart/element/element.dart';
import 'package:analyzer/source/line_info.dart' as server;
import 'package:analyzer/src/dartdoc/dartdoc_directive_info.dart';
import 'package:analyzer_plugin/src/utilities/client_uri_converter.dart';
import 'package:analyzer_plugin/src/utilities/documentation.dart';
import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart';
import 'package:collection/collection.dart';
import 'package:path/path.dart' as path;
/// Computes completion string, text to display and imports, if any for
/// an [OverrideSuggestion].
Future<OverrideData?> createOverrideSuggestionData(
OverrideSuggestion suggestion,
DartCompletionRequest request,
) async {
var displayTextBuffer = StringBuffer();
var overrideImports = <Uri>{};
var builder = ChangeBuilder(session: request.analysisSession);
await builder.addDartFileEdit(request.path, createEditsForImports: false, (
builder,
) {
builder.addReplacement(suggestion.replacementRange, (builder) {
builder.writeOverride(
suggestion.element,
displayTextBuffer: displayTextBuffer,
invokeSuper: suggestion.shouldInvokeSuper,
);
});
overrideImports.addAll(builder.requiredImports);
});
var fileEdits = builder.sourceChange.edits;
if (fileEdits.length != 1) {
return null;
}
var sourceEdits = fileEdits[0].edits;
if (sourceEdits.length != 1) {
return null;
}
var replacement = sourceEdits[0].replacement;
var completion = replacement.trim();
var overrideAnnotation = '@override';
if (request.target.containingNode.hasOverride &&
completion.startsWith(overrideAnnotation)) {
completion = completion.substring(overrideAnnotation.length).trim();
}
if (suggestion.skipAt && completion.startsWith(overrideAnnotation)) {
completion = completion.substring('@'.length);
}
if (completion.isEmpty) {
return null;
}
var selectionRange = builder.selectionRange;
if (selectionRange == null) {
return null;
}
var offsetDelta =
suggestion.replacementRange.offset + replacement.indexOf(completion);
var displayText = displayTextBuffer.toString();
if (displayText.isEmpty) {
return null;
}
if (suggestion.skipAt) {
displayText = 'override $displayText';
}
return OverrideData(
completion,
displayText,
overrideImports,
selectionRange.offset - offsetDelta,
selectionRange.length,
);
}
// TODO(keertip): Move over completions for plugins and snippets to use
// this function.
Future<lsp.CompletionItem?> toLspCompletionItem(
LspClientCapabilities capabilities,
server.LineInfo lineInfo,
CandidateSuggestion suggestion, {
required ClientUriConverter uriConverter,
required path.Context pathContext,
required String completionFilePath,
bool hasDefaultEditRange = false,
bool hasDefaultTextMode = false,
required lsp.Range replacementRange,
required lsp.Range insertionRange,
required DocumentationPreference includeDocumentation,
required bool commitCharactersEnabled,
required bool completeFunctionCalls,
lsp.CompletionItemResolutionInfo? resolutionData,
required DartCompletionRequest request,
}) async {
// isCallable is used to suffix the label with parens so it's clear the item
// is callable.
//
// isInvocation means the location at which it's used is an invocation (and
// therefore it is appropriate to include the parens/parameters in the
// inserted text).
//
// In the case of show combinators, the parens will still be shown to indicate
// functions but they should not be included in the completions.
var element =
suggestion is ElementBasedSuggestion
? (suggestion as ElementBasedSuggestion).element
: null;
var isCallable =
element != null &&
(element is ConstructorElement ||
element is LocalFunctionElement ||
element is TopLevelFunctionElement ||
element is MethodElement);
var isInvocation =
(suggestion is ExecutableSuggestion &&
suggestion.kind == server.CompletionSuggestionKind.INVOCATION) ||
suggestion is ClosureSuggestion ||
suggestion is FunctionCall;
if (!isCallable || !isInvocation) {
completeFunctionCalls = false;
}
var supportsCompletionDeprecatedFlag = capabilities.completionDeprecatedFlag;
var supportsDeprecatedTag = capabilities.completionItemTags.contains(
lsp.CompletionItemTag.Deprecated,
);
var formats = capabilities.completionDocumentationFormats;
var supportsSnippets = capabilities.completionSnippets;
var supportsInsertReplace = capabilities.insertReplaceCompletionRanges;
var supportsAsIsInsertMode = capabilities.completionInsertTextModes.contains(
lsp.InsertTextMode.asIs,
);
var useLabelDetails = capabilities.completionLabelDetails;
var label = _getDisplayText(suggestion, request);
if (label.isEmpty) {
return null;
}
// Displayed labels may have additional info appended (for example '(...)' on
// callables and ` => ` on getters) that should not be included in filterText,
// so strip anything from the first paren/space.
//
// Only do this if label doesn't start with the pattern, because if it does
// (for example for a closure `(a, b) {}`) we'll end up with an empty string
// but we should instead use the whole label.
// TODO(dantup): Consider including more of these raw fields in the original
// suggestion to avoid needing to manipulate them in this way here.
var filterText =
!label.startsWith(completionFilterTextSplitPattern)
? label.split(completionFilterTextSplitPattern).first.trim()
: label;
// If we're using label details, we also don't want the label to include any
// additional symbols as noted above, because they will appear in the extra
// details fields.
if (useLabelDetails) {
label = filterText;
}
// If this suggestion is an override, we always want to include "override" at
// the start of the label even if it's not there (which may be because the
// user has already typed it). We set this _after_ setting filterText because
// in that case, we do not want the client to rank this item badly because
// it starts "override" and the user is typing something different.
if (suggestion is OverrideSuggestion && !label.startsWith('override ')) {
label = 'override $label';
}
// Trim any trailing comma from the (displayed) label.
if (label.endsWith(',')) {
label = label.substring(0, label.length - 1);
}
var colorPreviewHex =
capabilities.completionItemKinds.contains(lsp.CompletionItemKind.Color) &&
suggestion is ElementBasedSuggestion
? server.getColorHexString(element)
: null;
var completionKind =
colorPreviewHex != null
? lsp.CompletionItemKind.Color
: _candidateToCompletionItemKind(
capabilities.completionItemKinds,
suggestion,
label,
);
var labelDetails = _getCompletionDetail(
suggestion,
isCallable: isCallable,
isInvocation: isInvocation,
supportsDeprecated:
supportsCompletionDeprecatedFlag || supportsDeprecatedTag,
);
// For legacy display, include short params on the end of labels as long as
// the item doesn't have custom display text (which may already include
// params).
if (!useLabelDetails &&
(suggestion is! ClosureSuggestion &&
suggestion is! OverrideSuggestion &&
suggestion is! SetStateMethodSuggestion)) {
label += labelDetails.truncatedParams;
}
List<String>? parameterNames;
CompletionDefaultArgumentList? defaultArgumentList;
String? cleanedDoc;
if (suggestion is ElementBasedSuggestion) {
var element = (suggestion as ElementBasedSuggestion).element;
if (element is ExecutableElement && element is! PropertyAccessorElement) {
parameterNames =
element.formalParameters.map((parameter) {
return parameter.displayName;
}).toList();
var requiredParameters = element.formalParameters.where(
(FormalParameterElement param) => param.isRequiredPositional,
);
var namedParameters = element.formalParameters.where(
(FormalParameterElement param) => param.isNamed,
);
defaultArgumentList = computeCompletionDefaultArgumentList(
element,
requiredParameters,
namedParameters,
);
}
cleanedDoc = _getDocumentation(element, request, includeDocumentation);
}
var completion = suggestion.completion;
var selectionOffset =
(suggestion is KeywordSuggestion)
? suggestion.selectionOffset
: completion.length;
var selectionLength = 0;
if (suggestion is SuggestionData) {
selectionOffset = (suggestion as SuggestionData).selectionOffset;
} else if (suggestion case OverrideSuggestion(:var data?)) {
selectionOffset = data.selectionOffset;
selectionLength = data.selectionLength;
}
var insertTextInfo = buildInsertText(
supportsSnippets: supportsSnippets,
commitCharactersEnabled: commitCharactersEnabled,
completeFunctionCalls: completeFunctionCalls,
requiredArgumentListString: defaultArgumentList?.text,
requiredArgumentListTextRanges: defaultArgumentList?.ranges,
hasOptionalParameters: parameterNames?.isNotEmpty ?? false,
completion: completion,
selectionOffset: selectionOffset,
selectionLength: selectionLength,
);
var insertText = insertTextInfo.text;
var insertTextFormat = insertTextInfo.format;
var isMultilineCompletion = insertText.contains('\n');
// To improve the display of some items (like pubspec version numbers),
// short labels in the format `_foo_` in docComplete are "upgraded" to the
// detail field.
var labelMatch =
cleanedDoc != null
? upgradableDocCompletePattern.firstMatch(cleanedDoc)
: null;
if (labelMatch != null) {
cleanedDoc = null;
labelDetails = (
detail: labelMatch.group(1)!,
truncatedParams: labelDetails.truncatedParams,
truncatedSignature: labelDetails.truncatedSignature,
autoImportUri: labelDetails.autoImportUri,
);
}
// Append hex colours to the end of the docs, this will allow editors that
// use a regex to find a color at the start/end like VS Code to show a color
// preview.
if (colorPreviewHex != null) {
cleanedDoc = '${cleanedDoc ?? ''}\n\n$colorPreviewHex'.trim();
}
var isDeprecated =
suggestion is ElementBasedSuggestion
? (suggestion as ElementBasedSuggestion)
.element
.hasOrInheritsDeprecated
: false;
// Because we potentially send thousands of these items, we should minimise
// the generated JSON as much as possible - for example using nulls in place
// of empty lists/false where possible.
return lsp.CompletionItem(
label: label,
kind: completionKind,
tags: nullIfEmpty([
if (supportsDeprecatedTag && isDeprecated)
lsp.CompletionItemTag.Deprecated,
]),
data: resolutionData,
detail: labelDetails.detail.nullIfEmpty,
labelDetails:
useLabelDetails
? lsp.CompletionItemLabelDetails(
detail: labelDetails.truncatedSignature.nullIfEmpty,
description: getCompletionDisplayUriString(
uriConverter: uriConverter,
pathContext: pathContext,
elementLibraryUri: labelDetails.autoImportUri,
completionFilePath: completionFilePath,
),
).nullIfEmpty
: null,
documentation:
cleanedDoc != null
? asMarkupContentOrString(formats, cleanedDoc)
: null,
deprecated: supportsCompletionDeprecatedFlag && isDeprecated ? true : null,
sortText: relevanceToSortText(suggestion.relevanceScore),
filterText: filterText.orNullIfSameAs(
label,
), // filterText uses label if not set
insertTextFormat:
insertTextFormat != lsp.InsertTextFormat.PlainText
? insertTextFormat
: null, // Defaults to PlainText if not supplied
insertTextMode:
!hasDefaultTextMode && supportsAsIsInsertMode && isMultilineCompletion
? lsp.InsertTextMode.asIs
: null,
// When using defaults for edit range, don't use textEdit.
textEdit:
hasDefaultEditRange
? null
: supportsInsertReplace && insertionRange != replacementRange
? lsp.Either2<lsp.InsertReplaceEdit, lsp.TextEdit>.t1(
lsp.InsertReplaceEdit(
insert: insertionRange,
replace: replacementRange,
newText: insertText,
),
)
: lsp.Either2<lsp.InsertReplaceEdit, lsp.TextEdit>.t2(
lsp.TextEdit(range: replacementRange, newText: insertText),
),
// When using defaults for edit range, use textEditText.
textEditText: hasDefaultEditRange ? insertText.orNullIfSameAs(label) : null,
);
}
/// Returns the [lsp.CompletionItemKind] or `null` for the given
/// [CandidateSuggestion] and the set of supported [lsp.CompletionItemKind]s.
lsp.CompletionItemKind? _candidateToCompletionItemKind(
Set<lsp.CompletionItemKind> supportedCompletionKinds,
CandidateSuggestion suggestion,
String label,
) {
bool isSupported(lsp.CompletionItemKind kind) =>
supportedCompletionKinds.contains(kind);
if (suggestion is ElementBasedSuggestion) {
return _elementToCompletionItemKind(
(suggestion as ElementBasedSuggestion).element,
supportedCompletionKinds,
).firstWhereOrNull(isSupported);
}
List<lsp.CompletionItemKind> getCompletionKind() {
switch (suggestion) {
case ClosureSuggestion():
return const [lsp.CompletionItemKind.Method];
case FunctionCall():
return const [lsp.CompletionItemKind.Method];
case IdentifierSuggestion():
return const [lsp.CompletionItemKind.Variable];
case KeywordSuggestion():
return const [lsp.CompletionItemKind.Keyword];
case LabelSuggestion():
// There isn't really a good CompletionItemKind for labels so we'll
// just use the Text option.
return const [lsp.CompletionItemKind.Text];
case NamedArgumentSuggestion():
return const [lsp.CompletionItemKind.Variable];
case NameSuggestion():
return const [lsp.CompletionItemKind.Variable];
case RecordFieldSuggestion():
return const [lsp.CompletionItemKind.Variable];
case RecordLiteralNamedFieldSuggestion():
return const [lsp.CompletionItemKind.Variable];
case UriSuggestion():
if (!label.startsWith('dart:')) {
return label.endsWith('.dart')
? const [
lsp.CompletionItemKind.File,
lsp.CompletionItemKind.Module,
]
: const [
lsp.CompletionItemKind.Folder,
lsp.CompletionItemKind.Module,
];
}
return const [lsp.CompletionItemKind.Module];
default:
return const [];
}
}
return getCompletionKind().firstWhereOrNull(isSupported);
}
/// Get the [lsp.CompletionItemKind] based on the [Element] for
/// an [ElementBasedSuggestion].
List<lsp.CompletionItemKind> _elementToCompletionItemKind(
Element element,
Set<lsp.CompletionItemKind> supportedCompletionKinds,
) {
if (element is ClassElement) {
return const [lsp.CompletionItemKind.Class];
}
if (element is ConstructorElement) {
return const [lsp.CompletionItemKind.Constructor];
}
if (element is EnumElement) {
return const [lsp.CompletionItemKind.Enum];
}
if (element is ExtensionElement) {
return const [lsp.CompletionItemKind.Method];
}
if (element is ExtensionTypeElement) {
return const [lsp.CompletionItemKind.Class];
}
if (element is FieldElement) {
if (element.isEnumConstant) {
return const [
lsp.CompletionItemKind.EnumMember,
lsp.CompletionItemKind.Enum,
];
}
return const [lsp.CompletionItemKind.Field];
}
if (element is LocalFunctionElement) {
return const [lsp.CompletionItemKind.Function];
}
if (element is TopLevelFunctionElement) {
return const [lsp.CompletionItemKind.Function];
}
if (element is LabelElement) {
return const [lsp.CompletionItemKind.Text];
}
if (element is LibraryElement) {
return const [lsp.CompletionItemKind.Module];
}
if (element is LocalVariableElement) {
return const [lsp.CompletionItemKind.Variable];
}
if (element is MethodElement) {
return const [lsp.CompletionItemKind.Method];
}
if (element is MixinElement) {
return const [lsp.CompletionItemKind.Class];
}
if (element is FormalParameterElement) {
return const [lsp.CompletionItemKind.Variable];
}
if (element is PrefixElement) {
return const [lsp.CompletionItemKind.Variable];
}
if (element is PropertyAccessorElement) {
return const [lsp.CompletionItemKind.Property];
}
if (element is TopLevelVariableElement) {
return const [lsp.CompletionItemKind.Variable];
}
if (element is TypeAliasElement) {
return const [lsp.CompletionItemKind.Class];
}
if (element is TypeParameterElement) {
return const [
lsp.CompletionItemKind.TypeParameter,
lsp.CompletionItemKind.Variable,
];
}
var kind = element.kind;
if (kind == ElementKind.PART) {
return const [lsp.CompletionItemKind.File, lsp.CompletionItemKind.Module];
}
if (kind == ElementKind.GENERIC_FUNCTION_TYPE) {
return const [lsp.CompletionItemKind.Class];
}
return const [];
}
/// Returns additional details to be shown against a completion.
CompletionDetail _getCompletionDetail(
CandidateSuggestion suggestion, {
required bool supportsDeprecated,
required bool isCallable,
required bool isInvocation,
}) {
String? returnType;
if (suggestion is FunctionCall) {
returnType = 'void';
} else if (suggestion is RecordFieldSuggestion) {
returnType = suggestion.field.type.getDisplayString();
}
var element =
suggestion is ElementBasedSuggestion
? (suggestion as ElementBasedSuggestion).element
: null;
// Usually getter/setters look the same in completion because they insert the
// same text. This is not the case for overrides because they will insert
// getter or setter stub code. To make this clear, we'll include get/set in
// the signature.
var isOverride = suggestion is OverrideSuggestion;
var isGetterOverride = false, isSetterOverride = false;
if (suggestion is OverrideSuggestion) {
isGetterOverride = element is GetterElement;
isSetterOverride = element is SetterElement;
}
if (suggestion is NamedArgumentSuggestion) {
element = suggestion.parameter;
}
String? parameters;
if (element != null) {
parameters = getParametersString(element);
// Prefer the element return type (because it may be more specific
// for overrides) and fall back to the parameter type or return type from
// the suggestion (handles records).
String? parameterType;
if (element is FormalParameterElement) {
parameterType = element.type.getDisplayString();
}
returnType = server.getReturnTypeString(element) ?? parameterType;
// Extract the type from setters to be shown in the place a return type
// would usually be shown.
if (returnType == null &&
element.kind == ElementKind.SETTER &&
parameters != null) {
returnType = completionSetterTypePattern.firstMatch(parameters)?.group(1);
parameters = null;
}
}
var truncatedParameters = switch (parameters) {
null || '' => '',
'()' => '()',
_ => '(…)',
};
var fullSignature = switch ((
parameters,
returnType,
isGetterOverride,
isSetterOverride,
)) {
(_, var returnType?, true, _) => '$returnType get',
(_, var returnType?, _, true) => 'set ($returnType)',
(null, _, _, _) => returnType ?? '',
(var parameters?, null || '', _, _) => parameters,
(var parameters?, var returnType?, _, _) => '$parameters → $returnType',
};
var truncatedSignature = switch ((
parameters,
returnType,
isGetterOverride,
isSetterOverride,
// When not callable/invocation/override, signatures will have a leading
// space, so that they are not formatted like calls, but the signature is
// instead just informational.
(isCallable && isInvocation) || isOverride,
)) {
// Include a leading space when no parameters so return type isn't right
// against the completion label.
(_, var returnType?, true, _, _) => ' $returnType get',
(_, var returnType?, _, true, _) => ' set ($returnType)',
(null, var returnType?, _, _, _) => ' $returnType',
(null || '', _, _, _, _) => '',
(_, null || '', _, _, true) => truncatedParameters,
(_, null || '', _, _, false) => ' $truncatedParameters',
(_, var returnType?, _, _, true) => '$truncatedParameters → $returnType',
(_, var returnType?, _, _, false) => ' $truncatedParameters → $returnType',
};
// Use the full signature in the details popup.
var detail = fullSignature;
if (element != null &&
(element is Annotatable &&
(element as Annotatable).metadata2.hasDeprecated) &&
!supportsDeprecated) {
// If the item is deprecated and we don't support the native deprecated flag
// then include it in the details.
detail = '$detail\n\n(Deprecated)'.trim();
}
var isNotImported = false;
String libraryUri = '';
if (suggestion is ImportableSuggestion) {
var importData = suggestion.importData;
if (importData != null) {
libraryUri = importData.libraryUri.toString();
isNotImported = importData.isNotImported;
}
}
var autoImportUri =
isNotImported && libraryUri.isNotEmpty ? Uri.parse(libraryUri) : null;
return (
detail: detail,
truncatedParams: truncatedParameters,
truncatedSignature: truncatedSignature,
autoImportUri: autoImportUri,
);
}
// Compute text to display for [suggestion].
String _getDisplayText(
CandidateSuggestion suggestion,
DartCompletionRequest request,
) {
if (suggestion is SuggestionData) {
return (suggestion as SuggestionData).displayText;
}
if (suggestion is FunctionCall) {
return 'call()';
}
if (suggestion is OverrideSuggestion) {
return suggestion.data?.displayText ?? suggestion.completion;
}
return suggestion.completion;
}
/// If the [element] has a documentation comment, return it.
_ElementDocumentation? _getDocsFromComputer(
Element element,
DartCompletionRequest request,
) {
var doc = request.documentationComputer.compute(
element,
includeSummary: true,
);
if (doc is DocumentationWithSummary) {
return _ElementDocumentation(full: doc.full, summary: doc.summary);
}
if (doc is Documentation) {
return _ElementDocumentation(full: doc.full, summary: null);
}
return null;
}
/// If the [element] has a documentation comment, return it.
String? _getDocumentation(
Element element,
DartCompletionRequest request,
DocumentationPreference includeDocumentation,
) {
var docs = _getDocsFromComputer(element, request);
var doc = removeDartDocDelimiters(docs?.full);
var rawDoc =
includeDocumentation == DocumentationPreference.full
? doc
: includeDocumentation == DocumentationPreference.summary
? getDartDocSummary(docs?.summary)
: null;
return cleanDartdoc(rawDoc);
}
/// Additional details about a completion that may be formatted differently
/// depending on the client capabilities.
typedef CompletionDetail =
({
/// Additional details to go in the details popup.
///
/// This is usually a full signature (with full parameters) and may also
/// include whether the item is deprecated if the client did not support the
/// native deprecated tag.
String detail,
/// Truncated parameters. Similar to [truncatedSignature] but does not
/// include return types. Used in clients that cannot format signatures
/// differently and is appended immediately after the completion label. The
/// return type is omitted to reduce noise because this text is not subtle.
String truncatedParams,
/// A signature with truncated params. Used for showing immediately after
/// the completion label when it can be formatted differently.
///
/// () → String
String truncatedSignature,
/// The URI that will be auto-imported if this item is selected in a
/// user-friendly string format (for example a relative path if for a `file:/`
/// URI).
Uri? autoImportUri,
});
class _ElementDocumentation {
final String full;
final String? summary;
_ElementDocumentation({required this.full, required this.summary});
}