blob: 5b9ed2ac167cf209c131994359032eceb8fea1f5 [file] [log] [blame]
// Copyright (c) 2019, 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/lsp_protocol/protocol.dart' hide Declaration;
import 'package:analysis_server/src/analysis_server.dart';
import 'package:analysis_server/src/collections.dart';
import 'package:analysis_server/src/computer/computer_documentation.dart';
import 'package:analysis_server/src/computer/computer_signature.dart' as server;
import 'package:analysis_server/src/lsp/client_capabilities.dart';
import 'package:analysis_server/src/lsp/constants.dart' as lsp;
import 'package:analysis_server/src/lsp/constants.dart';
import 'package:analysis_server/src/lsp/dartdoc.dart';
import 'package:analysis_server/src/lsp/error_or.dart';
import 'package:analysis_server/src/lsp/lsp_analysis_server.dart' as lsp;
import 'package:analysis_server/src/lsp/snippets.dart';
import 'package:analysis_server/src/lsp/source_edits.dart';
import 'package:analysis_server/src/protocol_server.dart'
as server
hide AnalysisError;
import 'package:analysis_server/src/services/completion/dart/dart_completion_suggestion.dart';
import 'package:analysis_server/src/services/completion/dart/feature_computer.dart';
import 'package:analysis_server/src/services/snippets/snippet.dart';
import 'package:analysis_server/src/utilities/extensions/string.dart';
import 'package:analyzer/dart/analysis/results.dart' as server;
import 'package:analyzer/dart/element/element.dart';
import 'package:analyzer/diagnostic/diagnostic.dart' as server;
import 'package:analyzer/error/error.dart' as server;
import 'package:analyzer/source/line_info.dart' as server;
import 'package:analyzer/source/line_info.dart';
import 'package:analyzer/source/source_range.dart' as server;
import 'package:analyzer/src/dart/analysis/search.dart'
as server
show DeclarationKind;
import 'package:analyzer/src/dart/element/element.dart';
import 'package:analyzer/src/error/codes.dart';
import 'package:analyzer/src/utilities/extensions/string.dart'
show IntExtension;
import 'package:analyzer_plugin/protocol/protocol_common.dart' as plugin;
import 'package:analyzer_plugin/src/utilities/client_uri_converter.dart';
import 'package:collection/collection.dart';
import 'package:path/path.dart' as path;
const languageSourceName = 'dart';
/// A regex used for splitting the display text in a completion so that
/// filterText only includes the symbol name and not any additional text (such
/// as parens, ` => `). Match `=>` but not `==` (which may appear in overrides).
final completionFilterTextSplitPattern = RegExp(r'=>|[\(]');
/// A regex to extract the type name from the parameter string of a setter
/// completion item.
final completionSetterTypePattern = RegExp(r'^\((\S+)\s+\S+\)$');
final diagnosticTagsForErrorCode = <String, List<lsp.DiagnosticTag>>{
_diagnosticCode(WarningCode.DEAD_CODE): [lsp.DiagnosticTag.Unnecessary],
_diagnosticCode(HintCode.DEPRECATED_MEMBER_USE_FROM_SAME_PACKAGE): [
lsp.DiagnosticTag.Deprecated,
],
_diagnosticCode(
HintCode.DEPRECATED_MEMBER_USE_FROM_SAME_PACKAGE_WITH_MESSAGE,
): [lsp.DiagnosticTag.Deprecated],
_diagnosticCode(HintCode.DEPRECATED_MEMBER_USE): [
lsp.DiagnosticTag.Deprecated,
],
'deprecated_member_use_from_same_package': [lsp.DiagnosticTag.Deprecated],
'deprecated_member_use_from_same_package_with_message': [
lsp.DiagnosticTag.Deprecated,
],
_diagnosticCode(HintCode.DEPRECATED_MEMBER_USE_WITH_MESSAGE): [
lsp.DiagnosticTag.Deprecated,
],
};
/// The value to subtract relevance from to get the correct sortText for a
/// completion item.
final sortTextMaxValue = int.parse('9' * maximumRelevance.toString().length);
/// Pattern for docComplete text on completion items that can be upgraded to
/// the "detail" field so that it can be shown more prominently by clients.
///
/// This is typically used for labels like _latest compatible_ and _latest_ in
/// the pubspec version items. These go into docComplete so that they appear
/// reasonably for non-LSP clients where there is no equivalent of the detail
/// field.
final upgradableDocCompletePattern = RegExp(r'^_([\w ]{0,20})_$');
lsp.Either2<lsp.MarkupContent, String> asMarkupContentOrString(
Set<lsp.MarkupKind>? preferredFormats,
String content,
) {
return preferredFormats != null
? lsp.Either2<lsp.MarkupContent, String>.t1(
_asMarkup(preferredFormats, content),
)
: lsp.Either2<lsp.MarkupContent, String>.t2(content);
}
({String text, lsp.InsertTextFormat format}) buildInsertText({
required bool supportsSnippets,
required bool commitCharactersEnabled,
required bool completeFunctionCalls,
required String? requiredArgumentListString,
required List<int>? requiredArgumentListTextRanges,
required bool hasOptionalParameters,
required String completion,
required int selectionOffset,
required int selectionLength,
}) {
var insertText = completion;
var insertTextFormat = lsp.InsertTextFormat.PlainText;
// SuggestionBuilder already does the equiv of completeFunctionCalls for
// some methods (for example Flutter's setState). If the completion already
// includes any `(` then disable our own insertion as the special-cased code
// will likely provide better code.
if (completion.contains('(')) {
completeFunctionCalls = false;
}
// If the client supports snippets, we can support completeFunctionCalls or
// setting a selection.
if (supportsSnippets) {
// completeFunctionCalls should only work if commit characters are disabled
// otherwise the editor may insert parens that we're also inserting.
if (!commitCharactersEnabled && completeFunctionCalls) {
insertTextFormat = lsp.InsertTextFormat.Snippet;
var hasRequiredParameters =
requiredArgumentListTextRanges?.isNotEmpty ?? false;
var functionCallSuffix =
hasRequiredParameters && requiredArgumentListString != null
? buildSnippetStringWithTabStops(
requiredArgumentListString,
requiredArgumentListTextRanges,
)
// Optional params still gets a final tab stop in the parens.
: hasOptionalParameters
? SnippetBuilder.finalTabStop
// And no parameters at all we skip the tabstop in the parens.
: '';
insertText =
'${SnippetBuilder.escapeSnippetPlainText(insertText)}($functionCallSuffix)';
} else if (selectionOffset != 0 &&
// We don't need a tab stop if the selection is the end of the string.
selectionOffset != completion.length) {
insertTextFormat = lsp.InsertTextFormat.Snippet;
insertText = buildSnippetStringWithTabStops(completion, [
selectionOffset,
selectionLength,
]);
}
}
return (text: insertText, format: insertTextFormat);
}
/// Creates a [lsp.WorkspaceEdit] from simple [server.SourceFileEdit]s.
///
/// [clientCapabilities] should be for the client that will handle this edit,
/// which is not necessarily the client that triggered the request that called
/// this function (for example a DTD client may call a request that triggers an
/// edit that will be sent to the editor).
///
/// If [annotateChanges] is set, change annotations will be produced and
/// marked as needing confirmation from the user (depending on the value).
///
/// Note: This code will fetch the version of each document being modified so
/// it's important to call this immediately after computing edits to ensure
/// the document is not modified before the version number is read.
lsp.WorkspaceEdit createPlainWorkspaceEdit(
AnalysisServer analysisServer,
LspClientCapabilities clientCapabilities,
List<server.SourceFileEdit> edits, {
ChangeAnnotations annotateChanges = ChangeAnnotations.none,
String? filePath,
LineInfo? lineInfo,
}) {
return toWorkspaceEdit(
annotateChanges: annotateChanges,
clientCapabilities,
edits.map((e) {
// If we don't expet to create the file use the passed line info if any
// and it matches the given file.
// If we expect to create the file, `server.getLineInfo()` won't
// provide a LineInfo so create one from empty contents.
LineInfo pickedLineInfo;
if (e.fileStamp == -1) {
pickedLineInfo = LineInfo.fromContent('');
} else {
if (filePath != null && lineInfo != null && filePath == e.file) {
pickedLineInfo = lineInfo;
} else {
pickedLineInfo = analysisServer.getLineInfo(e.file)!;
}
}
return FileEditInformation(
analysisServer.getVersionedDocumentIdentifier(e.file),
pickedLineInfo,
e.edits,
// `fileStamp == 1` is used by the server to indicate the file needs
// creating.
newFile: e.fileStamp == -1,
);
}).toList(),
);
}
/// Create a [WorkspaceEdit] that renames [oldPath] to [newPath].
WorkspaceEdit createRenameEdit(
ClientUriConverter uriConverter,
String oldPath,
String newPath,
) {
var changes =
<Either4<CreateFile, DeleteFile, RenameFile, TextDocumentEdit>>[];
var rename = RenameFile(
oldUri: uriConverter.toClientUri(oldPath),
newUri: uriConverter.toClientUri(newPath),
);
var renameUnion =
Either4<CreateFile, DeleteFile, RenameFile, TextDocumentEdit>.t3(rename);
changes.add(renameUnion);
var edit = WorkspaceEdit(documentChanges: changes);
return edit;
}
/// Creates a [lsp.WorkspaceEdit] from a [server.SourceChange].
///
/// Can return experimental [lsp.SnippetTextEdit]s if the following are true:
/// - the client has indicated support for in the experimental section of their
/// client capabilities, and
/// - [allowSnippets] is true, and
/// - [change] contains only a single edit to the single file [filePath]
/// - [lineInfo] is provided (which should be for the single edited file)
///
/// Note: This code will fetch the version of each document being modified so
/// it's important to call this immediately after computing edits to ensure
/// the document is not modified before the version number is read.
lsp.WorkspaceEdit createWorkspaceEdit(
AnalysisServer analysisServer,
LspClientCapabilities clientCapabilities,
server.SourceChange change, {
ChangeAnnotations annotateChanges = ChangeAnnotations.none,
// The caller must specify whether snippets are valid here for where they're
// sending this edit. Right now, support is limited to CodeActions.
bool allowSnippets = false,
String? filePath,
LineInfo? lineInfo,
}) {
assert(
annotateChanges == ChangeAnnotations.none || !allowSnippets,
'annotateChanges is not supported with snippets',
);
// In order to return snippets, we must ensure we are only modifying a single
// existing file with a single edit and that there is either a selection or a
// linked edit group (otherwise there's no value in snippets).
if (!allowSnippets ||
!clientCapabilities.experimentalSnippetTextEdit ||
!clientCapabilities.documentChanges ||
filePath == null ||
lineInfo == null ||
change.edits.length != 1 ||
change.edits.single.fileStamp == -1 || // new file
change.edits.single.file != filePath ||
change.edits.single.edits.length != 1 ||
(change.selection == null && change.linkedEditGroups.isEmpty)) {
return createPlainWorkspaceEdit(
analysisServer,
clientCapabilities,
change.edits,
annotateChanges: annotateChanges,
filePath: filePath,
lineInfo: lineInfo,
);
}
var fileEdit = change.edits.single;
var snippetEdits = toSnippetTextEdits(
fileEdit.file,
fileEdit,
change.linkedEditGroups,
lineInfo,
selectionOffset: change.selection?.offset,
selectionLength: change.selectionLength,
);
// Compile the edits into a TextDocumentEdit for this file.
var textDocumentEdit = lsp.TextDocumentEdit(
textDocument: analysisServer.getVersionedDocumentIdentifier(fileEdit.file),
edits:
snippetEdits
.map(
(e) => Either3<
lsp.AnnotatedTextEdit,
lsp.SnippetTextEdit,
lsp.TextEdit
>.t2(e),
)
.toList(),
);
// Convert to the union that documentChanges require.
var textDocumentEditsAsUnion = Either4<
lsp.CreateFile,
lsp.DeleteFile,
lsp.RenameFile,
lsp.TextDocumentEdit
>.t4(textDocumentEdit);
/// Add the textDocumentEdit to a WorkspaceEdit.
return lsp.WorkspaceEdit(documentChanges: [textDocumentEditsAsUnion]);
}
lsp.SymbolKind declarationKindToSymbolKind(
Set<lsp.SymbolKind> supportedSymbolKinds,
server.DeclarationKind? kind,
) {
bool isSupported(lsp.SymbolKind kind) => supportedSymbolKinds.contains(kind);
List<lsp.SymbolKind> getKindPreferences() {
switch (kind) {
case server.DeclarationKind.CLASS:
case server.DeclarationKind.CLASS_TYPE_ALIAS:
return const [lsp.SymbolKind.Class];
case server.DeclarationKind.CONSTRUCTOR:
return const [lsp.SymbolKind.Constructor];
case server.DeclarationKind.ENUM:
return const [lsp.SymbolKind.Enum];
case server.DeclarationKind.ENUM_CONSTANT:
return const [lsp.SymbolKind.EnumMember, lsp.SymbolKind.Enum];
case server.DeclarationKind.EXTENSION:
return const [lsp.SymbolKind.Class];
case server.DeclarationKind.EXTENSION_TYPE:
return const [lsp.SymbolKind.Class];
case server.DeclarationKind.FIELD:
return const [lsp.SymbolKind.Field];
case server.DeclarationKind.FUNCTION:
return const [lsp.SymbolKind.Function];
case server.DeclarationKind.FUNCTION_TYPE_ALIAS:
return const [lsp.SymbolKind.Class];
case server.DeclarationKind.GETTER:
return const [lsp.SymbolKind.Property];
case server.DeclarationKind.METHOD:
return const [lsp.SymbolKind.Method];
case server.DeclarationKind.MIXIN:
return const [lsp.SymbolKind.Class];
case server.DeclarationKind.SETTER:
return const [lsp.SymbolKind.Property];
case server.DeclarationKind.TYPE_ALIAS:
return const [lsp.SymbolKind.Class];
case server.DeclarationKind.VARIABLE:
return const [lsp.SymbolKind.Variable];
default:
// Assert that we only get here if kind=null. If it's anything else
// then we're missing a mapping from above.
assert(kind == null, 'Unexpected declaration kind $kind');
return const [];
}
}
// LSP requires we specify *some* kind, so in the case where the above code doesn't
// match we'll just have to send a value to avoid a crash.
return getKindPreferences().firstWhere(
isSupported,
orElse: () => lsp.SymbolKind.Obj,
);
}
lsp.CompletionItemKind? elementKindToCompletionItemKind(
Set<lsp.CompletionItemKind> supportedCompletionKinds,
server.ElementKind kind,
) {
bool isSupported(lsp.CompletionItemKind kind) =>
supportedCompletionKinds.contains(kind);
List<lsp.CompletionItemKind> getKindPreferences() {
switch (kind) {
case server.ElementKind.CLASS:
case server.ElementKind.CLASS_TYPE_ALIAS:
return const [lsp.CompletionItemKind.Class];
case server.ElementKind.COMPILATION_UNIT:
return const [
lsp.CompletionItemKind.File,
lsp.CompletionItemKind.Module,
];
case server.ElementKind.CONSTRUCTOR:
case server.ElementKind.CONSTRUCTOR_INVOCATION:
return const [lsp.CompletionItemKind.Constructor];
case server.ElementKind.ENUM:
return const [lsp.CompletionItemKind.Enum];
case server.ElementKind.ENUM_CONSTANT:
return const [
lsp.CompletionItemKind.EnumMember,
lsp.CompletionItemKind.Enum,
];
case server.ElementKind.FIELD:
return const [lsp.CompletionItemKind.Field];
case server.ElementKind.FILE:
return const [lsp.CompletionItemKind.File];
case server.ElementKind.FUNCTION:
return const [lsp.CompletionItemKind.Function];
case server.ElementKind.FUNCTION_TYPE_ALIAS:
return const [lsp.CompletionItemKind.Class];
case server.ElementKind.GETTER:
return const [lsp.CompletionItemKind.Property];
case server.ElementKind.LABEL:
// There isn't really a good CompletionItemKind for labels so we'll
// just use the Text option.
return const [lsp.CompletionItemKind.Text];
case server.ElementKind.LIBRARY:
return const [lsp.CompletionItemKind.Module];
case server.ElementKind.LOCAL_VARIABLE:
return const [lsp.CompletionItemKind.Variable];
case server.ElementKind.METHOD:
return const [lsp.CompletionItemKind.Method];
case server.ElementKind.MIXIN:
return const [lsp.CompletionItemKind.Class];
case server.ElementKind.PARAMETER:
case server.ElementKind.PREFIX:
return const [lsp.CompletionItemKind.Variable];
case server.ElementKind.SETTER:
return const [lsp.CompletionItemKind.Property];
case server.ElementKind.TOP_LEVEL_VARIABLE:
return const [lsp.CompletionItemKind.Variable];
case server.ElementKind.TYPE_PARAMETER:
return const [
lsp.CompletionItemKind.TypeParameter,
lsp.CompletionItemKind.Variable,
];
case server.ElementKind.UNIT_TEST_GROUP:
case server.ElementKind.UNIT_TEST_TEST:
return const [lsp.CompletionItemKind.Method];
default:
return const [];
}
}
return getKindPreferences().firstWhereOrNull(isSupported);
}
lsp.SymbolKind elementKindToSymbolKind(
Set<lsp.SymbolKind> supportedSymbolKinds,
server.ElementKind? kind,
) {
bool isSupported(lsp.SymbolKind kind) => supportedSymbolKinds.contains(kind);
List<lsp.SymbolKind> getKindPreferences() {
switch (kind) {
case server.ElementKind.CLASS:
case server.ElementKind.CLASS_TYPE_ALIAS:
return const [lsp.SymbolKind.Class];
case server.ElementKind.COMPILATION_UNIT:
return const [lsp.SymbolKind.File];
case server.ElementKind.CONSTRUCTOR:
case server.ElementKind.CONSTRUCTOR_INVOCATION:
return const [lsp.SymbolKind.Constructor];
case server.ElementKind.ENUM:
return const [lsp.SymbolKind.Enum];
case server.ElementKind.ENUM_CONSTANT:
return const [lsp.SymbolKind.EnumMember, lsp.SymbolKind.Enum];
case server.ElementKind.EXTENSION:
return const [lsp.SymbolKind.Namespace];
case server.ElementKind.EXTENSION_TYPE:
return const [lsp.SymbolKind.Namespace];
case server.ElementKind.FIELD:
return const [lsp.SymbolKind.Field];
case server.ElementKind.FILE:
return const [lsp.SymbolKind.File];
case server.ElementKind.FUNCTION:
case server.ElementKind.FUNCTION_INVOCATION:
return const [lsp.SymbolKind.Function];
case server.ElementKind.FUNCTION_TYPE_ALIAS:
return const [lsp.SymbolKind.Class];
case server.ElementKind.GETTER:
return const [lsp.SymbolKind.Property];
case server.ElementKind.LABEL:
// There isn't really a good SymbolKind for labels so we'll
// just use the Null option.
return const [lsp.SymbolKind.Null];
case server.ElementKind.LIBRARY:
return const [lsp.SymbolKind.Namespace];
case server.ElementKind.LOCAL_VARIABLE:
return const [lsp.SymbolKind.Variable];
case server.ElementKind.METHOD:
return const [lsp.SymbolKind.Method];
case server.ElementKind.MIXIN:
return const [lsp.SymbolKind.Class];
case server.ElementKind.PARAMETER:
case server.ElementKind.PREFIX:
return const [lsp.SymbolKind.Variable];
case server.ElementKind.SETTER:
return const [lsp.SymbolKind.Property];
case server.ElementKind.TOP_LEVEL_VARIABLE:
return const [lsp.SymbolKind.Variable];
case server.ElementKind.TYPE_PARAMETER:
return const [lsp.SymbolKind.TypeParameter, lsp.SymbolKind.Variable];
case server.ElementKind.UNIT_TEST_GROUP:
case server.ElementKind.UNIT_TEST_TEST:
return const [lsp.SymbolKind.Method];
default:
// Assert that we only get here if kind=null. If it's anything else
// then we're missing a mapping from above.
assert(kind == null, 'Unexpected element kind $kind');
return const [];
}
}
// LSP requires we specify *some* kind, so in the case where the above code doesn't
// match we'll just have to send a value to avoid a crash.
return getKindPreferences().firstWhere(
isSupported,
orElse: () => lsp.SymbolKind.Obj,
);
}
lsp.Location? fragmentToLocation(
ClientUriConverter uriConverter,
Fragment? fragment,
) {
if (fragment == null) {
return null;
}
var libraryFragment = fragment.libraryFragment!;
var sourcePath = libraryFragment.source.fullName;
int? nameOffset;
int? nameLength;
if (fragment case PropertyAccessorFragmentImpl(
:var isSynthetic,
:var nonSynthetic,
) when isSynthetic) {
var element = nonSynthetic;
nameOffset = element.nameOffset.nullIfNegative;
nameLength = element.name?.length;
} else {
nameOffset = fragment.nameOffset2;
nameLength = fragment.name2?.length;
}
// For unnamed constructors, use the type name as the target location.
if (nameOffset == null && fragment is ConstructorFragment) {
nameOffset = fragment.typeNameOffset;
nameLength = fragment.typeName?.length;
}
if (nameOffset == null || nameLength == null) {
// This is some kind of synthetic fragment we can't navigate to.
return null;
}
return lsp.Location(
uri: uriConverter.toClientUri(sourcePath),
range: toRange(libraryFragment.lineInfo, nameOffset, nameLength),
);
}
/// Returns additional details to be shown against a completion.
CompletionDetail getCompletionDetail(
server.CompletionSuggestion suggestion, {
required bool supportsDeprecated,
}) {
var element = suggestion.element;
var parameters = element?.parameters;
// 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).
var returnType =
element?.returnType ?? suggestion.parameterType ?? suggestion.returnType;
// Extract the type from setters to be shown in the place a return type
// would usually be shown.
if (returnType == null &&
element?.kind == server.ElementKind.SETTER &&
parameters != null) {
returnType = completionSetterTypePattern.firstMatch(parameters)?.group(1);
parameters = null;
}
var truncatedParameters = switch (parameters) {
null || '' => '',
'()' => '()',
_ => '(…)',
};
var fullSignature = switch ((parameters, returnType)) {
(null, _) => returnType ?? '',
(var parameters?, null) => parameters,
(var parameters?, '') => parameters,
(var parameters?, _) => '$parameters → $returnType',
};
var truncatedSignature = switch ((parameters, returnType)) {
(null, null) => '',
// Include a leading space when no parameters so return type isn't right
// against the completion label.
(null, var returnType?) => ' $returnType',
(_, null) || (_, '') => truncatedParameters,
(_, var returnType?) => '$truncatedParameters → $returnType',
};
// Use the full signature in the details popup.
var detail = fullSignature;
if (suggestion.isDeprecated && !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 libraryUri = suggestion.libraryUri;
var autoImportUri =
(suggestion.isNotImported ?? false) && libraryUri != null
? Uri.parse(libraryUri)
: null;
return (
detail: detail,
truncatedParams: truncatedParameters,
truncatedSignature: truncatedSignature,
autoImportUri: autoImportUri,
);
}
/// Gets a library URI formatted for display in code completion as the target
/// library that a symbol comes from.
///
/// File URIs will be made relative to [completionFilePath]. Other URIs will be
/// returned as-is.
String? getCompletionDisplayUriString({
required ClientUriConverter uriConverter,
required path.Context pathContext,
required Uri? elementLibraryUri,
required String completionFilePath,
}) {
if (elementLibraryUri == null) {
return null;
}
return elementLibraryUri.isScheme('file')
// Compute the relative path and then put into a URI so the display
// always uses forward slashes (as a URI) regardless of platform.
? uriConverter
.toClientUri(
pathContext.relative(
uriConverter.fromClientUri(elementLibraryUri),
from: pathContext.dirname(completionFilePath),
),
)
.toString()
: elementLibraryUri.toString();
}
List<lsp.DiagnosticTag>? getDiagnosticTags(
Set<lsp.DiagnosticTag>? supportedTags,
plugin.AnalysisError error,
) {
if (supportedTags == null) {
return null;
}
var tags =
diagnosticTagsForErrorCode[error.code]
?.where(supportedTags.contains)
.toList();
return tags != null && tags.isNotEmpty ? tags : null;
}
bool isDartDocument(lsp.TextDocumentIdentifier doc) => isDartUri(doc.uri);
bool isDartUri(Uri uri) => uri.path.endsWith('.dart');
/// Converts a [server.Location] to an [lsp.Range] by translating the
/// offset/length using a `LineInfo`.
///
/// This function ignores any line/column info on the
/// [server.Location] assuming it is either not available not unreliable.
lsp.Range locationOffsetLenToRange(
server.LineInfo lineInfo,
server.Location location,
) => toRange(lineInfo, location.offset, location.length);
/// Converts a [server.Location] to an [lsp.Range] if all line and column
/// values are available.
///
/// Returns null if any values are -1 or null.
lsp.Range? locationToRange(server.Location location) {
var startLine = location.startLine;
var startColumn = location.startColumn;
var endLine = location.endLine ?? -1;
var endColumn = location.endColumn ?? -1;
if (startLine == -1 ||
startColumn == -1 ||
endLine == -1 ||
endColumn == -1) {
return null;
}
// LSP positions are 0-based but Location is 1-based.
return Range(
start: Position(line: startLine - 1, character: startColumn - 1),
end: Position(line: endLine - 1, character: endColumn - 1),
);
}
/// Merges two [WorkspaceEdit]s into a single one.
///
/// Will throw if given [WorkspaceEdit]s that do not use documentChanges.
WorkspaceEdit mergeWorkspaceEdits(List<WorkspaceEdit> edits) {
// TODO(dantup): This method (and much other code here) should be
// significantly tidied up when nonfunction-type-aliases is available here.
var changes =
<Either4<CreateFile, DeleteFile, RenameFile, TextDocumentEdit>>[];
for (var edit in edits) {
changes.addAll(edit.documentChanges!);
}
return WorkspaceEdit(documentChanges: changes);
}
lsp.Location navigationTargetToLocation(
Uri targetFileUri,
server.NavigationTarget target,
server.LineInfo targetLineInfo,
) {
return lsp.Location(
uri: targetFileUri,
range: toRange(targetLineInfo, target.offset, target.length),
);
}
lsp.LocationLink? navigationTargetToLocationLink(
server.NavigationRegion region,
server.LineInfo regionLineInfo,
Uri targetFileUri,
server.NavigationTarget target,
server.LineInfo targetLineInfo,
) {
var nameRange = toRange(targetLineInfo, target.offset, target.length);
var codeOffset = target.codeOffset;
var codeLength = target.codeLength;
var codeRange =
codeOffset != null && codeLength != null
? toRange(targetLineInfo, codeOffset, codeLength)
: nameRange;
return lsp.LocationLink(
originSelectionRange: toRange(regionLineInfo, region.offset, region.length),
targetUri: targetFileUri,
targetRange: codeRange,
targetSelectionRange: nameRange,
);
}
lsp.Diagnostic pluginToDiagnostic(
ClientUriConverter uriConverter,
server.LineInfo? Function(String) getLineInfo,
plugin.AnalysisError error, {
required Set<lsp.DiagnosticTag>? supportedTags,
required bool clientSupportsCodeDescription,
}) {
List<lsp.DiagnosticRelatedInformation>? relatedInformation;
var contextMessages = error.contextMessages;
if (contextMessages != null && contextMessages.isNotEmpty) {
relatedInformation =
contextMessages
.map(
(message) => pluginToDiagnosticRelatedInformation(
uriConverter,
getLineInfo,
message,
),
)
.nonNulls
.toList();
}
var message = error.message;
if (error.correction != null) {
message = '$message\n${error.correction}';
}
var range =
locationToRange(error.location) ??
locationOffsetLenToRange(
// TODO(dantup): This null assertion is not sound and can lead to
// errors (for example during a large rename where files may be
// removed as diagnostics are being mapped). To remove this,
// error.location should be updated to require line/col information
// (which involves breaking changes).
getLineInfo(error.location.file)!,
error.location,
);
var documentationUrl = error.url;
return lsp.Diagnostic(
range: range,
severity: pluginToDiagnosticSeverity(error.severity),
code: error.code,
source: languageSourceName,
message: message,
tags: getDiagnosticTags(supportedTags, error),
relatedInformation: relatedInformation,
// Only include codeDescription if the client explicitly supports it
// (a minor optimization to avoid unnecessary payload/(de)serialization).
codeDescription:
clientSupportsCodeDescription && documentationUrl != null
? CodeDescription(href: Uri.parse(documentationUrl))
: null,
);
}
lsp.DiagnosticRelatedInformation? pluginToDiagnosticRelatedInformation(
ClientUriConverter uriConverter,
server.LineInfo? Function(String) getLineInfo,
plugin.DiagnosticMessage message,
) {
var file = message.location.file;
var uri = uriConverter.toClientUri(file);
var lineInfo = getLineInfo(file);
// We shouldn't get context messages for something we can't get a LineInfo for
// but if we did, it's better to omit the context than fail to send the errors.
if (lineInfo == null) {
return null;
}
return lsp.DiagnosticRelatedInformation(
location: lsp.Location(
uri: uri,
// TODO(dantup): Switch to using line/col information from the context
// message once confirmed that AnalyzerConverter is not using the wrong
// LineInfo.
range: toRange(
lineInfo,
message.location.offset,
message.location.length,
),
),
message: message.message,
);
}
lsp.DiagnosticSeverity pluginToDiagnosticSeverity(
plugin.AnalysisErrorSeverity severity,
) {
return switch (severity) {
plugin.AnalysisErrorSeverity.ERROR => lsp.DiagnosticSeverity.Error,
plugin.AnalysisErrorSeverity.WARNING => lsp.DiagnosticSeverity.Warning,
plugin.AnalysisErrorSeverity.INFO => lsp.DiagnosticSeverity.Information,
};
}
/// Records a change annotation for [edit] in [uri] into the [changeAnnotations]
/// map based on the value of [annotateChanges].
ChangeAnnotation? recordEditAnnotation(
Uri uri,
server.SourceEdit edit, {
required ChangeAnnotations annotateChanges,
required Map<ChangeAnnotationIdentifier, ChangeAnnotation>? changeAnnotations,
}) {
assert(
(annotateChanges == ChangeAnnotations.none) == (changeAnnotations == null),
);
if (changeAnnotations == null) {
return null;
}
// Always try to provide good descriptions when producing annotated
// changes but use a fallback rather than failing if they're not
// available.
// When running with asserts, assert there is a description to
// highlight where we're not passing them.
assert(edit.description != null);
var label = edit.description ?? edit.id ?? uri.pathSegments.last;
return changeAnnotations[label] ??= ChangeAnnotation(
label: label,
needsConfirmation: annotateChanges == ChangeAnnotations.requireConfirmation,
);
}
/// Converts a numeric relevance to a sortable string.
///
/// The servers relevance value is a number with highest being best. LSP uses a
/// a string sort on the `sortText` field. Subtracting the relevance from a
/// large number will produce text that will sort correctly.
///
/// Relevance can be 0, so it's important to subtract from a number like 999
/// and not 1000 or the 0 relevance items will sort at the top instead of the
/// bottom.
///
/// 1000000 -> 9999999 - 1000000 -> 8 999 999
/// 555 -> 9999999 - 555 -> 9 999 444
/// 10 -> 9999999 - 10 -> 9 999 989
/// 1 -> 9999999 - 1 -> 9 999 998
/// 0 -> 9999999 - 0 -> 9 999 999
String relevanceToSortText(int relevance) =>
(sortTextMaxValue - relevance).toString();
/// Creates a SnippetTextEdit for a set of edits using Linked Edit Groups.
///
/// Edit groups offsets are based on the entire content being modified after all
/// edits, so [editOffset] must to take into account both the offset of the edit
/// _and_ any delta from edits prior to this one in the file.
///
/// [selectionOffset] is also absolute and assumes `edit.replacement` will be
/// inserted at [editOffset].
lsp.SnippetTextEdit snippetTextEditFromEditGroups(
String filePath,
server.LineInfo lineInfo,
server.SourceEdit edit, {
required List<server.LinkedEditGroup> editGroups,
required int editOffset,
required int? selectionOffset,
required int? selectionLength,
}) {
return lsp.SnippetTextEdit(
insertTextFormat: lsp.InsertTextFormat.Snippet,
range: toRange(lineInfo, edit.offset, edit.length),
newText: buildSnippetStringForEditGroups(
edit.replacement,
filePath: filePath,
editGroups: editGroups,
editOffset: editOffset,
selectionOffset: selectionOffset,
selectionLength: selectionLength,
),
);
}
/// Creates a SnippetTextEdit for an edit with a selection placeholder.
///
/// [selectionOffsetRelative] is relative to (and therefore must be within) the
/// edit.
lsp.SnippetTextEdit snippetTextEditWithSelection(
server.LineInfo lineInfo,
server.SourceEdit edit, {
required int selectionOffsetRelative,
int? selectionLength,
}) {
return lsp.SnippetTextEdit(
insertTextFormat: lsp.InsertTextFormat.Snippet,
range: toRange(lineInfo, edit.offset, edit.length),
newText: buildSnippetStringWithTabStops(edit.replacement, [
selectionOffsetRelative,
selectionLength ?? 0,
]),
);
}
lsp.CompletionItem snippetToCompletionItem(
lsp.LspAnalysisServer server,
LspClientCapabilities capabilities,
String file,
LineInfo lineInfo,
Position position,
Snippet snippet,
CompletionItemDefaults? defaults,
) {
assert(capabilities.completionSnippets);
var formats = capabilities.completionDocumentationFormats;
var documentation = snippet.documentation;
var supportsAsIsInsertMode = capabilities.completionInsertTextModes.contains(
InsertTextMode.asIs,
);
var changes = snippet.change;
// We must only get one change for this file to be able to apply snippets.
var thisFilesChange = changes.edits.singleWhere((e) => e.file == file);
var otherFilesChanges = changes.edits.where((e) => e.file != file).toList();
// If this completion involves editing other files, we'll need to build
// a command that the client will call to apply those edits later, because
// LSP Completions can only provide simple edits for the current file.
Command? command;
if (otherFilesChanges.isNotEmpty) {
var workspaceEdit = createPlainWorkspaceEdit(
server,
capabilities,
otherFilesChanges,
);
command = Command(
title: 'Add import',
command: Commands.sendWorkspaceEdit,
arguments: [
{'edit': workspaceEdit},
],
);
}
/// Convert the changes to TextEdits using snippet tokens for linked edit
/// groups.
var mainFileEdits = toSnippetTextEdits(
file,
thisFilesChange,
changes.linkedEditGroups,
lineInfo,
selectionOffset:
changes.selection?.file == file ? changes.selection?.offset : null,
selectionLength:
changes.selection?.file == file ? changes.selectionLength : null,
);
// For LSP, we need to provide the main edit and other edits separately. The
// main edit must include the location that completion was invoked. If we find
// more than one, take the first one since imports are usually added as later
// edits (so when applied sequentially they will be inserted at the start of
// the file after the other edits).
var mainEdit = mainFileEdits.firstWhere(
(edit) => edit.range.start.line == position.line,
);
var nonMainEdits = mainFileEdits.where((edit) => edit != mainEdit).toList();
// Capture any default combined range. If there are different insert/replace
// ranges just take `null` because snippets always use the same ranges and
// if defaults are different ours can't possibly be redundant.
var defaultRange = defaults?.editRange?.map(
(ranges) => null,
(range) => range,
);
var hasDefaultEditRange = mainEdit.range == defaultRange;
return lsp.CompletionItem(
label: snippet.label,
filterText: snippet.prefix.orNullIfSameAs(snippet.label),
kind: lsp.CompletionItemKind.Snippet,
command: command,
documentation:
documentation != null
? asMarkupContentOrString(formats, documentation)
: null,
// Force snippets to be sorted at the bottom of the list.
// TODO(dantup): Consider if we can rank these better. Client-side
// snippets have always been forced to the bottom partly because they
// show up in more places than wanted.
sortText: 'zzz${snippet.prefix}',
insertTextFormat: lsp.InsertTextFormat.Snippet,
insertTextMode: supportsAsIsInsertMode ? InsertTextMode.asIs : null,
// Set textEdit or textEditText depending on whether we need to specify
// a range or not.
textEdit:
hasDefaultEditRange
? null
: Either2<InsertReplaceEdit, TextEdit>.t2(mainEdit),
textEditText:
hasDefaultEditRange
? mainEdit.newText.orNullIfSameAs(snippet.label)
: null,
additionalTextEdits: nonMainEdits.nullIfEmpty,
);
}
/// Sorts a list of [server.SourceEdit]s for mapping to LSP types.
///
/// Server works with edits that can be applied sequentially to a [String]. This
/// means inserts at the same offset are in the reverse order. For LSP, all
/// offsets relate to the original document and inserts with the same offset
/// appear in the order they will appear in the final document.
List<server.SourceEdit> sortSourceEditsForLsp(List<server.SourceEdit> edits) {
// Since for LSP the ordering of items without the same offset do not matter,
// we can simply reverse the entire list.
return edits.reversed.toList();
}
lsp.CompletionItemKind? suggestionKindToCompletionItemKind(
Set<lsp.CompletionItemKind> supportedCompletionKinds,
server.CompletionSuggestionKind kind,
String label,
) {
bool isSupported(lsp.CompletionItemKind kind) =>
supportedCompletionKinds.contains(kind);
List<lsp.CompletionItemKind> getKindPreferences() {
switch (kind) {
case server.CompletionSuggestionKind.ARGUMENT_LIST:
return const [lsp.CompletionItemKind.Variable];
case server.CompletionSuggestionKind.IMPORT:
// For package/relative URIs, we can send File/Folder kinds for better icons.
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];
case server.CompletionSuggestionKind.IDENTIFIER:
return const [lsp.CompletionItemKind.Variable];
case server.CompletionSuggestionKind.INVOCATION:
return const [lsp.CompletionItemKind.Method];
case server.CompletionSuggestionKind.KEYWORD:
return const [lsp.CompletionItemKind.Keyword];
case server.CompletionSuggestionKind.NAMED_ARGUMENT:
return const [lsp.CompletionItemKind.Variable];
case server.CompletionSuggestionKind.OPTIONAL_ARGUMENT:
return const [lsp.CompletionItemKind.Variable];
case server.CompletionSuggestionKind.PARAMETER:
return const [lsp.CompletionItemKind.Value];
case server.CompletionSuggestionKind.PACKAGE_NAME:
return const [lsp.CompletionItemKind.Module];
default:
return const [];
}
}
return getKindPreferences().firstWhereOrNull(isSupported);
}
lsp.ClosingLabel toClosingLabel(
server.LineInfo lineInfo,
server.ClosingLabel label,
) => lsp.ClosingLabel(
range: toRange(lineInfo, label.offset, label.length),
label: label.label,
);
/// Converts [id] to a [CodeActionKind] using [fallbackOrPrefix] as a fallback
/// or a prefix if the ID is not already a fix/refactor.
lsp.CodeActionKind toCodeActionKind(
String? id,
lsp.CodeActionKind fallbackOrPrefix,
) {
if (id == null) {
return fallbackOrPrefix;
}
// Dart fixes and assists start with "dart.assist." and "dart.fix." but in LSP
// we want to use the predefined prefixes for CodeActions.
var newId = id
.replaceAll('dart.assist', lsp.CodeActionKind.Refactor.toString())
.replaceAll('dart.fix', lsp.CodeActionKind.QuickFix.toString())
.replaceAll(
'analysisOptions.assist',
lsp.CodeActionKind.Refactor.toString(),
)
.replaceAll('analysisOptions.fix', lsp.CodeActionKind.QuickFix.toString())
.replaceAll('pubspec.assist', lsp.CodeActionKind.Refactor.toString())
.replaceAll('pubspec.fix', lsp.CodeActionKind.QuickFix.toString());
// If the ID does not start with either of the kinds above, prefix it as
// it will be an unqualified ID from a plugin.
if (!newId.startsWith(lsp.CodeActionKind.Refactor.toString()) &&
!newId.startsWith(lsp.CodeActionKind.QuickFix.toString())) {
newId = '$fallbackOrPrefix.$newId';
}
return lsp.CodeActionKind(newId);
}
lsp.CompletionItem toCompletionItem(
LspClientCapabilities capabilities,
server.LineInfo lineInfo,
server.CompletionSuggestion suggestion, {
required ClientUriConverter uriConverter,
required path.Context pathContext,
required String completionFilePath,
bool hasDefaultEditRange = false,
bool hasDefaultTextMode = false,
required Range replacementRange,
required Range insertionRange,
required DocumentationPreference includeDocumentation,
required bool commitCharactersEnabled,
required bool completeFunctionCalls,
CompletionItemResolutionInfo? resolutionData,
}) {
// 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 elementKind = suggestion.element?.kind;
var isCallable =
elementKind == server.ElementKind.CONSTRUCTOR ||
elementKind == server.ElementKind.FUNCTION ||
elementKind == server.ElementKind.METHOD;
var isInvocation =
suggestion.kind == server.CompletionSuggestionKind.INVOCATION;
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(
InsertTextMode.asIs,
);
var useLabelDetails = capabilities.completionLabelDetails;
var label = suggestion.displayText ?? suggestion.completion;
assert(label.isNotEmpty);
// 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;
}
// Trim any trailing comma from the (displayed) label.
if (label.endsWith(',')) {
label = label.substring(0, label.length - 1);
}
var element = suggestion.element;
var colorPreviewHex =
capabilities.completionItemKinds.contains(CompletionItemKind.Color) &&
suggestion is DartCompletionSuggestion
? suggestion.colorHex
: null;
var completionKind =
colorPreviewHex != null
? CompletionItemKind.Color
: element != null
? elementKindToCompletionItemKind(
capabilities.completionItemKinds,
element.kind,
)
: suggestionKindToCompletionItemKind(
capabilities.completionItemKinds,
suggestion.kind,
label,
);
var labelDetails = getCompletionDetail(
suggestion,
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.displayText == null) {
label += labelDetails.truncatedParams;
}
var insertTextInfo = buildInsertText(
supportsSnippets: supportsSnippets,
commitCharactersEnabled: commitCharactersEnabled,
completeFunctionCalls: completeFunctionCalls,
requiredArgumentListString: suggestion.defaultArgumentListString,
requiredArgumentListTextRanges: suggestion.defaultArgumentListTextRanges,
hasOptionalParameters: suggestion.parameterNames?.isNotEmpty ?? false,
completion: suggestion.completion,
selectionOffset: suggestion.selectionOffset,
selectionLength: suggestion.selectionLength,
);
var insertText = insertTextInfo.text;
var insertTextFormat = insertTextInfo.format;
var isMultilineCompletion = insertText.contains('\n');
var rawDoc =
includeDocumentation == DocumentationPreference.full
? suggestion.docComplete
: includeDocumentation == DocumentationPreference.summary
? suggestion.docSummary
: null;
var cleanedDoc = cleanDartdoc(rawDoc);
// 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();
}
// 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 && suggestion.isDeprecated)
lsp.CompletionItemTag.Deprecated,
]),
data: resolutionData,
detail: labelDetails.detail.nullIfEmpty,
labelDetails:
useLabelDetails
? 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 && suggestion.isDeprecated
? true
: null,
sortText: relevanceToSortText(suggestion.relevance),
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
? InsertTextMode.asIs
: null,
// When using defaults for edit range, don't use textEdit.
textEdit:
hasDefaultEditRange
? null
: supportsInsertReplace && insertionRange != replacementRange
? Either2<InsertReplaceEdit, TextEdit>.t1(
InsertReplaceEdit(
insert: insertionRange,
replace: replacementRange,
newText: insertText,
),
)
: Either2<InsertReplaceEdit, TextEdit>.t2(
TextEdit(range: replacementRange, newText: insertText),
),
// When using defaults for edit range, use textEditText.
textEditText: hasDefaultEditRange ? insertText.orNullIfSameAs(label) : null,
);
}
lsp.Diagnostic toDiagnostic(
ClientUriConverter uriConverter,
server.ResolvedUnitResult result,
server.Diagnostic diagnostic, {
required Set<lsp.DiagnosticTag> supportedTags,
required bool clientSupportsCodeDescription,
}) {
return pluginToDiagnostic(
uriConverter,
(_) => result.lineInfo,
server.newAnalysisError_fromEngine(result, diagnostic),
supportedTags: supportedTags,
clientSupportsCodeDescription: clientSupportsCodeDescription,
);
}
lsp.Element toElement(server.LineInfo lineInfo, server.Element element) {
var location = element.location;
return lsp.Element(
range:
location != null
? toRange(lineInfo, location.offset, location.length)
: null,
name: toElementName(element),
kind: element.kind.name,
parameters: element.parameters,
typeParameters: element.typeParameters,
returnType: element.returnType,
);
}
String toElementName(server.Element element) {
return element.name.isNotEmpty
? element.name
: (element.kind == server.ElementKind.EXTENSION
? '<unnamed extension>'
: '<unnamed>');
}
lsp.FlutterOutline toFlutterOutline(
server.LineInfo lineInfo,
server.FlutterOutline outline,
) {
var attributes = outline.attributes;
var dartElement = outline.dartElement;
var children = outline.children;
return lsp.FlutterOutline(
kind: outline.kind.name,
label: outline.label,
className: outline.className,
variableName: outline.variableName,
attributes:
attributes
?.map((attribute) => toFlutterOutlineAttribute(lineInfo, attribute))
.toList(),
dartElement: dartElement != null ? toElement(lineInfo, dartElement) : null,
range: toRange(lineInfo, outline.offset, outline.length),
codeRange: toRange(lineInfo, outline.codeOffset, outline.codeLength),
children: children?.map((c) => toFlutterOutline(lineInfo, c)).toList(),
);
}
lsp.FlutterOutlineAttribute toFlutterOutlineAttribute(
server.LineInfo lineInfo,
server.FlutterOutlineAttribute attribute,
) {
var valueLocation = attribute.valueLocation;
return lsp.FlutterOutlineAttribute(
name: attribute.name,
label: attribute.label,
valueRange:
valueLocation != null
? toRange(lineInfo, valueLocation.offset, valueLocation.length)
: null,
);
}
lsp.FoldingRangeKind? toFoldingRangeKind(server.FoldingKind kind) {
switch (kind) {
case server.FoldingKind.COMMENT:
case server.FoldingKind.DOCUMENTATION_COMMENT:
case server.FoldingKind.FILE_HEADER:
return lsp.FoldingRangeKind.Comment;
case server.FoldingKind.DIRECTIVES:
return lsp.FoldingRangeKind.Imports;
default:
// null (actually undefined in LSP, the toJson() takes care of that) is
// valid, and actually the value used for the majority of folds
// (class/functions/etc.).
return null;
}
}
lsp.Location toLocation(
ClientUriConverter uriConverter,
server.Location location,
server.LineInfo lineInfo,
) => lsp.Location(
uri: uriConverter.toClientUri(location.file),
range: toRange(lineInfo, location.offset, location.length),
);
ErrorOr<int> toOffset(
server.LineInfo lineInfo,
lsp.Position pos, {
bool failureIsCritical = false,
}) {
// line is zero-based so cannot equal lineCount
if (pos.line >= lineInfo.lineCount) {
return ErrorOr<int>.error(
lsp.ResponseError(
code:
failureIsCritical
? lsp.ServerErrorCodes.ClientServerInconsistentState
: lsp.ServerErrorCodes.InvalidFileLineCol,
message: 'Invalid line number',
data: pos.line.toString(),
),
);
}
// TODO(dantup): Is there any way to validate the character? We could ensure
// it's less than the offset of the next line, but that would only work for
// all lines except the last one.
return ErrorOr<int>.success(
lineInfo.getOffsetOfLine(pos.line) + pos.character,
);
}
lsp.Outline toOutline(server.LineInfo lineInfo, server.Outline outline) {
var children = outline.children;
return lsp.Outline(
element: toElement(lineInfo, outline.element),
range: toRange(lineInfo, outline.offset, outline.length),
codeRange: toRange(lineInfo, outline.codeOffset, outline.codeLength),
children: children?.map((c) => toOutline(lineInfo, c)).toList(),
);
}
lsp.Position toPosition(server.CharacterLocation location) {
// LSP is zero-based, but analysis server is 1-based.
return lsp.Position(
line: location.lineNumber - 1,
character: location.columnNumber - 1,
);
}
lsp.Range toRange(server.LineInfo lineInfo, int offset, int length) {
assert(offset >= 0);
assert(length >= 0);
var start = lineInfo.getLocation(offset);
var end = lineInfo.getLocation(offset + length);
return lsp.Range(start: toPosition(start), end: toPosition(end));
}
lsp.SignatureHelp toSignatureHelp(
Set<lsp.MarkupKind>? preferredFormats,
server.SignatureInformation signature,
) {
// For now, we only support returning one (though we may wish to use named
// args. etc. to provide one for each possible "next" option when the cursor
// is at the end ready to provide another argument).
/// Gets the label for an individual parameter in the form
/// String s = 'foo'
String getParamLabel(FormalParameterElement p) {
var defaultCodeSuffix =
p.defaultValueCode != null ? ' = ${p.defaultValueCode}' : '';
var requiredPrefix = p.isRequiredNamed ? 'required ' : '';
return '$requiredPrefix${p.type} ${p.displayName}$defaultCodeSuffix';
}
/// Gets the full signature label in the form
/// foo(String s, int i, bool a = true)
String getSignatureLabel(server.SignatureInformation resp) {
var positionalRequired =
signature.parameters.where((p) => p.isRequiredPositional).toList();
var positionalOptional =
signature.parameters.where((p) => p.isOptionalPositional).toList();
var named = signature.parameters.where((p) => p.isNamed).toList();
var params = [
if (positionalRequired.isNotEmpty)
positionalRequired.map(getParamLabel).join(', '),
if (positionalOptional.isNotEmpty)
'[${positionalOptional.map(getParamLabel).join(', ')}]',
if (named.isNotEmpty) '{${named.map(getParamLabel).join(', ')}}',
];
return '${resp.name}(${params.join(", ")})';
}
lsp.ParameterInformation toParameterInfo(FormalParameterElement param) {
// LSP 3.14.0 supports providing label offsets (to avoid clients having
// to guess based on substrings). We should check the
// signatureHelp.signatureInformation.parameterInformation.labelOffsetSupport
// capability when deciding to send that.
return lsp.ParameterInformation(label: getParamLabel(param));
}
var cleanedDoc = cleanDartdoc(signature.dartdoc);
return lsp.SignatureHelp(
signatures: [
lsp.SignatureInformation(
label: getSignatureLabel(signature),
documentation:
cleanedDoc != null
? asMarkupContentOrString(preferredFormats, cleanedDoc)
: null,
parameters: signature.parameters.map(toParameterInfo).toList(),
),
],
activeSignature: 0, // activeSignature
// We must provide a unsigned integer here but it's possible there isn't
// a valid value (because the user might be in the 10th argument of an
// invocation that only takes 1). The LSP spec allows us to send an
// out-of-bounds value so send the first out-of-bound value (`.length`). The
// spec says this may be treated as 0, however VS Code will not highlight
// any parameter in this case (which is preferred and hopefully other
// clients may copy).
activeParameter:
signature.activeParameterIndex ?? signature.parameters.length,
);
}
List<lsp.SnippetTextEdit> toSnippetTextEdits(
String filePath,
server.SourceFileEdit change,
List<server.LinkedEditGroup> editGroups,
LineInfo lineInfo, {
required int? selectionOffset,
required int? selectionLength,
}) {
var snippetEdits = <lsp.SnippetTextEdit>[];
// Edit groups offsets are based on the document after the edits are applied.
// This means we must compute an offset delta for each edit that takes into
// account all edits that might be made before it in the document (which are
// after it in the edits). To do this, reverse the list when computing the
// offsets, but reverse them back to the original list order when returning so
// that we do not apply them incorrectly in tests (where we will apply them
// in-sequence).
var offsetDelta = 0;
for (var edit in change.edits.reversed) {
snippetEdits.add(
snippetTextEditFromEditGroups(
filePath,
lineInfo,
edit,
editGroups: editGroups,
editOffset: edit.offset + offsetDelta,
selectionOffset: selectionOffset,
selectionLength: selectionLength,
),
);
offsetDelta += edit.replacement.length - edit.length;
}
return snippetEdits.reversed.toList();
}
ErrorOr<server.SourceRange> toSourceRange(
server.LineInfo lineInfo,
Range range,
) {
// If there is a range, convert to offsets because that's what
// the tokens are computed using initially.
var start = toOffset(lineInfo, range.start);
var end = toOffset(lineInfo, range.end);
return (start, end).mapResultsSync(
(start, end) => success(server.SourceRange(start, end - start)),
);
}
ErrorOr<server.SourceRange?> toSourceRangeNullable(
server.LineInfo lineInfo,
Range? range,
) => range != null ? toSourceRange(lineInfo, range) : success(null);
/// Creates an [lsp.TextDocumentEdit] for [fileEdit].
///
/// If [changeAnnotations] is not `null`, change annotations will be appended
/// for each edit produced and marked as requiring user confirmation.
lsp.TextDocumentEdit toTextDocumentEdit(
LspClientCapabilities capabilities,
FileEditInformation fileEdit, {
ChangeAnnotations annotateChanges = ChangeAnnotations.none,
Map<ChangeAnnotationIdentifier, ChangeAnnotation>? changeAnnotations,
}) {
assert(
(annotateChanges == ChangeAnnotations.none) == (changeAnnotations == null),
);
return lsp.TextDocumentEdit(
textDocument: fileEdit.doc,
edits:
sortSourceEditsForLsp(fileEdit.edits).map((edit) {
var annotation = recordEditAnnotation(
fileEdit.doc.uri,
edit,
annotateChanges: annotateChanges,
changeAnnotations: changeAnnotations,
);
return toTextDocumentEditEdit(
capabilities,
fileEdit.lineInfo,
edit,
selectionOffsetRelative: fileEdit.selectionOffsetRelative,
selectionLength: fileEdit.selectionLength,
annotationIdentifier: annotation?.label,
);
}).toList(),
);
}
Either3<lsp.AnnotatedTextEdit, lsp.SnippetTextEdit, lsp.TextEdit>
toTextDocumentEditEdit(
LspClientCapabilities capabilities,
server.LineInfo lineInfo,
server.SourceEdit edit, {
int? selectionOffsetRelative,
int? selectionLength,
lsp.ChangeAnnotationIdentifier? annotationIdentifier,
}) {
if (annotationIdentifier != null) {
return Either3<lsp.AnnotatedTextEdit, lsp.SnippetTextEdit, lsp.TextEdit>.t1(
lsp.AnnotatedTextEdit(
annotationId: annotationIdentifier,
range: toRange(lineInfo, edit.offset, edit.length),
newText: edit.replacement,
),
);
}
if (!capabilities.experimentalSnippetTextEdit ||
selectionOffsetRelative == null) {
return Either3<lsp.AnnotatedTextEdit, lsp.SnippetTextEdit, lsp.TextEdit>.t3(
toTextEdit(lineInfo, edit),
);
}
return Either3<lsp.AnnotatedTextEdit, lsp.SnippetTextEdit, lsp.TextEdit>.t2(
snippetTextEditWithSelection(
lineInfo,
edit,
selectionOffsetRelative: selectionOffsetRelative,
selectionLength: selectionLength,
),
);
}
lsp.TextEdit toTextEdit(
server.LineInfo lineInfo,
server.SourceEdit edit, {
ChangeAnnotation? annotation,
}) {
return annotation != null
? lsp.AnnotatedTextEdit(
range: toRange(lineInfo, edit.offset, edit.length),
newText: edit.replacement,
annotationId: annotation.label,
)
: lsp.TextEdit(
range: toRange(lineInfo, edit.offset, edit.length),
newText: edit.replacement,
);
}
/// Creates an [lsp.WorkspaceEdit] for [edits].
///
/// [clientCapabilities] should be for the client that will handle this edit,
/// which is not necessarily the client that triggered the request that called
/// this function (for example a DTD client may call a request that triggers an
/// edit that will be sent to the editor).
///
/// If [annotateChanges] is set, change annotations will be produced and
/// marked as needing confirmation from the user (depending on the value).
lsp.WorkspaceEdit toWorkspaceEdit(
LspClientCapabilities clientCapabilities,
List<FileEditInformation> edits, {
ChangeAnnotations annotateChanges = ChangeAnnotations.none,
}) {
var supportsDocumentChanges = clientCapabilities.documentChanges;
var changeAnnotations =
annotateChanges != ChangeAnnotations.none
? <lsp.ChangeAnnotationIdentifier, ChangeAnnotation>{}
: null;
if (supportsDocumentChanges) {
var supportsCreate = clientCapabilities.createResourceOperations;
var changes =
<
Either4<
lsp.CreateFile,
lsp.DeleteFile,
lsp.RenameFile,
lsp.TextDocumentEdit
>
>[];
// Convert each SourceEdit to either a TextDocumentEdit or a
// CreateFile + a TextDocumentEdit depending on whether it's a new
// file.
for (var fileEdit in edits) {
if (supportsCreate && fileEdit.newFile) {
var create = lsp.CreateFile(uri: fileEdit.doc.uri);
var createUnion = Either4<
lsp.CreateFile,
lsp.DeleteFile,
lsp.RenameFile,
lsp.TextDocumentEdit
>.t1(create);
changes.add(createUnion);
}
var textDocEdit = toTextDocumentEdit(
clientCapabilities,
fileEdit,
annotateChanges: annotateChanges,
changeAnnotations: changeAnnotations,
);
var textDocEditUnion = Either4<
lsp.CreateFile,
lsp.DeleteFile,
lsp.RenameFile,
lsp.TextDocumentEdit
>.t4(textDocEdit);
changes.add(textDocEditUnion);
}
return lsp.WorkspaceEdit(
documentChanges: changes,
changeAnnotations: changeAnnotations,
);
} else {
return lsp.WorkspaceEdit(
changes: toWorkspaceEditChanges(
edits,
annotateChanges: annotateChanges,
changeAnnotations: changeAnnotations,
),
changeAnnotations: changeAnnotations,
);
}
}
Map<Uri, List<lsp.TextEdit>> toWorkspaceEditChanges(
List<FileEditInformation> edits, {
ChangeAnnotations annotateChanges = ChangeAnnotations.none,
Map<ChangeAnnotationIdentifier, ChangeAnnotation>? changeAnnotations,
}) {
MapEntry<Uri, List<lsp.TextEdit>> createEdit(FileEditInformation file) {
var edits =
sortSourceEditsForLsp(file.edits).map((edit) {
var annotation = recordEditAnnotation(
file.doc.uri,
edit,
annotateChanges: annotateChanges,
changeAnnotations: changeAnnotations,
);
return toTextEdit(file.lineInfo, edit, annotation: annotation);
}).toList();
return MapEntry(file.doc.uri, edits);
}
return Map<Uri, List<lsp.TextEdit>>.fromEntries(edits.map(createEdit));
}
lsp.MarkupContent _asMarkup(
Set<lsp.MarkupKind> preferredFormats,
String content,
) {
if (preferredFormats.isEmpty) {
preferredFormats.add(lsp.MarkupKind.Markdown);
}
var supportsMarkdown = preferredFormats.contains(lsp.MarkupKind.Markdown);
var supportsPlain = preferredFormats.contains(lsp.MarkupKind.PlainText);
// Since our PlainText version is actually just Markdown, only advertise it
// as PlainText if the client explicitly supports PlainText and not Markdown.
var format =
supportsPlain && !supportsMarkdown
? lsp.MarkupKind.PlainText
: lsp.MarkupKind.Markdown;
return lsp.MarkupContent(kind: format, value: content);
}
String _diagnosticCode(server.DiagnosticCode code) => code.name.toLowerCase();
/// 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,
});
extension CompletionLabelExtension on CompletionItemLabelDetails {
/// Returns `null` if no fields are set, otherwise `this`.
CompletionItemLabelDetails? get nullIfEmpty =>
detail != null || description != null ? this : null;
}
extension _ListExtensions<T> on List<T> {
/// Returns `null` if this list is empty, otherwise `this`.
List<T>? get nullIfEmpty => isEmpty ? null : this;
}