| // 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/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/list.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/diagnostic/diagnostic.dart' as diag; | |
| 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(diag.deadCode): [lsp.DiagnosticTag.Unnecessary], | |
| _diagnosticCode(diag.deprecatedMemberUse): [lsp.DiagnosticTag.Deprecated], | |
| 'deprecated_member_use_from_same_package': [lsp.DiagnosticTag.Deprecated], | |
| 'deprecated_member_use_from_same_package_with_message': [ | |
| lsp.DiagnosticTag.Deprecated, | |
| ], | |
| _diagnosticCode(diag.deprecatedMemberUseWithMessage): [ | |
| 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 include [lsp.SnippetTextEdit]s or [lsp.LegacySnippetTextEdit] if the | |
| /// client has indicated support for one or the other, 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.legacySnippetTextEdit && | |
| !clientCapabilities.snippetTextEdit) || | |
| !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 = clientCapabilities.snippetTextEdit | |
| ? toSnippetTextEdits( | |
| fileEdit.file, | |
| fileEdit, | |
| change.linkedEditGroups, | |
| lineInfo, | |
| selectionOffset: change.selection?.offset, | |
| selectionLength: change.selectionLength, | |
| ) | |
| .map( | |
| Either4< | |
| AnnotatedTextEdit, | |
| LegacySnippetTextEdit, | |
| SnippetTextEdit, | |
| TextEdit | |
| > | |
| .t3, | |
| ) | |
| .toList() | |
| : toLegacySnippetTextEdits( | |
| fileEdit.file, | |
| fileEdit, | |
| change.linkedEditGroups, | |
| lineInfo, | |
| selectionOffset: change.selection?.offset, | |
| selectionLength: change.selectionLength, | |
| ) | |
| .map( | |
| Either4< | |
| AnnotatedTextEdit, | |
| LegacySnippetTextEdit, | |
| SnippetTextEdit, | |
| TextEdit | |
| > | |
| .t2, | |
| ) | |
| .toList(); | |
| // Compile the edits into a TextDocumentEdit for this file. | |
| var textDocumentEdit = lsp.TextDocumentEdit( | |
| textDocument: analysisServer.getVersionedDocumentIdentifier(fileEdit.file), | |
| edits: snippetEdits, | |
| ); | |
| // 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 isOriginDeclaration) | |
| when !isOriginDeclaration) { | |
| var element = fragment.element.nonSynthetic; | |
| nameOffset = element.firstFragment.nameOffset; | |
| nameLength = element.firstFragment.name?.length; | |
| } else { | |
| nameOffset = fragment.nameOffset; | |
| nameLength = fragment.name?.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)?[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'); | |
| /// Creates a [lsp.LegacySnippetTextEdit] 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.LegacySnippetTextEdit legacySnippetTextEditFromEditGroups( | |
| String filePath, | |
| server.LineInfo lineInfo, | |
| server.SourceEdit edit, { | |
| required List<server.LinkedEditGroup> editGroups, | |
| required int editOffset, | |
| required int? selectionOffset, | |
| required int? selectionLength, | |
| }) { | |
| return lsp.LegacySnippetTextEdit( | |
| 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 [lsp.LegacySnippetTextEdit] for an edit with a selection | |
| /// placeholder. | |
| /// | |
| /// [selectionOffsetRelative] is relative to (and therefore must be within) the | |
| /// edit. | |
| lsp.LegacySnippetTextEdit legacySnippetTextEditWithSelection( | |
| server.LineInfo lineInfo, | |
| server.SourceEdit edit, { | |
| required int selectionOffsetRelative, | |
| int? selectionLength, | |
| }) { | |
| return lsp.LegacySnippetTextEdit( | |
| insertTextFormat: lsp.InsertTextFormat.Snippet, | |
| range: toRange(lineInfo, edit.offset, edit.length), | |
| newText: buildSnippetStringWithTabStops(edit.replacement, [ | |
| selectionOffsetRelative, | |
| selectionLength ?? 0, | |
| ]), | |
| ); | |
| } | |
| /// 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, | |
| required bool clientSupportsDiagnosticData, | |
| }) { | |
| 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 && !clientSupportsDiagnosticData) { | |
| 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: .t2(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, | |
| data: clientSupportsDiagnosticData | |
| ? { | |
| 'offset': error.location.offset, | |
| 'length': error.location.length, | |
| 'type': error.type.name, | |
| 'correctionMessage': ?error.correction, | |
| } | |
| : 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 [lsp.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( | |
| range: toRange(lineInfo, edit.offset, edit.length), | |
| snippet: StringValue( | |
| value: buildSnippetStringForEditGroups( | |
| edit.replacement, | |
| filePath: filePath, | |
| editGroups: editGroups, | |
| editOffset: editOffset, | |
| selectionOffset: selectionOffset, | |
| selectionLength: selectionLength, | |
| ), | |
| ), | |
| ); | |
| } | |
| /// Creates a [lsp.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( | |
| range: toRange(lineInfo, edit.offset, edit.length), | |
| snippet: StringValue( | |
| value: buildSnippetStringWithTabStops(edit.replacement, [ | |
| selectionOffsetRelative, | |
| selectionLength ?? 0, | |
| ]), | |
| ), | |
| ); | |
| } | |
| lsp.CompletionItem snippetToCompletionItem( | |
| AnalysisServer 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 = toLegacySnippetTextEdits( | |
| 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, | |
| CompletionResolutionInfo? 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[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, | |
| required bool clientSupportsDiagnosticData, | |
| }) { | |
| return pluginToDiagnostic( | |
| uriConverter, | |
| (_) => result.lineInfo, | |
| server.newAnalysisError_fromEngine(result, diagnostic), | |
| supportedTags: supportedTags, | |
| clientSupportsCodeDescription: clientSupportsCodeDescription, | |
| clientSupportsDiagnosticData: clientSupportsDiagnosticData, | |
| ); | |
| } | |
| 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 | |
| ? (element.extendedType?.isNotEmpty ?? false) | |
| ? 'extension on ${element.extendedType}' | |
| : '<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; | |
| } | |
| } | |
| List<lsp.LegacySnippetTextEdit> toLegacySnippetTextEdits( | |
| String filePath, | |
| server.SourceFileEdit change, | |
| List<server.LinkedEditGroup> editGroups, | |
| LineInfo lineInfo, { | |
| required int? selectionOffset, | |
| required int? selectionLength, | |
| }) { | |
| var snippetEdits = <lsp.LegacySnippetTextEdit>[]; | |
| // 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( | |
| legacySnippetTextEditFromEditGroups( | |
| filePath, | |
| lineInfo, | |
| edit, | |
| editGroups: editGroups, | |
| editOffset: edit.offset + offsetDelta, | |
| selectionOffset: selectionOffset, | |
| selectionLength: selectionLength, | |
| ), | |
| ); | |
| offsetDelta += edit.replacement.length - edit.length; | |
| } | |
| return snippetEdits.reversed.toList(); | |
| } | |
| 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, | |
| ); | |
| } | |
| ErrorOr<int> toOffsetFromOffsetLineAndColumn( | |
| int? offsetOfLine, | |
| int column, | |
| int originalLine, { | |
| bool failureIsCritical = false, | |
| }) { | |
| if (offsetOfLine == null) { | |
| return ErrorOr<int>.error( | |
| lsp.ResponseError( | |
| code: failureIsCritical | |
| ? lsp.ServerErrorCodes.clientServerInconsistentState | |
| : lsp.ServerErrorCodes.invalidFileLineCol, | |
| message: 'Invalid line number', | |
| data: originalLine.toString(), | |
| ), | |
| ); | |
| } | |
| return ErrorOr<int>.success(offsetOfLine + column); | |
| } | |
| 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( | |
| server.SignatureInformation signature, { | |
| required Set<lsp.MarkupKind>? preferredFormats, | |
| required bool clientSupportsNullActiveParameter, | |
| }) { | |
| // 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 prefix = p.isRequiredNamed ? 'required ' : ''; | |
| return '$prefix${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 | |
| activeParameter: | |
| signature.activeParameterIndex ?? | |
| // If the client doesn't support `null`, we still must provide an | |
| // unsigned integer. 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). | |
| (clientSupportsNullActiveParameter | |
| ? null | |
| : 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(), | |
| ); | |
| } | |
| /// Converts a [server.SourceEdit] into an LSP edit depending on the clients | |
| /// capabilities for snippets and/or annotated changes. | |
| Either4<AnnotatedTextEdit, LegacySnippetTextEdit, SnippetTextEdit, TextEdit> | |
| toTextDocumentEditEdit( | |
| LspClientCapabilities capabilities, | |
| server.LineInfo lineInfo, | |
| server.SourceEdit edit, { | |
| int? selectionOffsetRelative, | |
| int? selectionLength, | |
| lsp.ChangeAnnotationIdentifier? annotationIdentifier, | |
| }) { | |
| var prefersSnippet = selectionOffsetRelative != null; | |
| if (prefersSnippet && capabilities.snippetTextEdit) { | |
| return .t3( | |
| snippetTextEditWithSelection( | |
| lineInfo, | |
| edit, | |
| selectionOffsetRelative: selectionOffsetRelative, | |
| selectionLength: selectionLength, | |
| ), | |
| ); | |
| } else if (prefersSnippet && capabilities.legacySnippetTextEdit) { | |
| return .t2( | |
| legacySnippetTextEditWithSelection( | |
| lineInfo, | |
| edit, | |
| selectionOffsetRelative: selectionOffsetRelative, | |
| selectionLength: selectionLength, | |
| ), | |
| ); | |
| } else if (annotationIdentifier != null) { | |
| return .t1( | |
| lsp.AnnotatedTextEdit( | |
| annotationId: annotationIdentifier, | |
| range: toRange(lineInfo, edit.offset, edit.length), | |
| newText: edit.replacement, | |
| ), | |
| ); | |
| } else { | |
| return .t4(toTextEdit(lineInfo, edit)); | |
| } | |
| } | |
| 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.lowerCaseName; | |
| /// 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; | |
| } |