| // 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 'dart:math'; |
| |
| import 'package:analysis_server/lsp_protocol/protocol_custom_generated.dart' |
| as lsp; |
| import 'package:analysis_server/lsp_protocol/protocol_custom_generated.dart'; |
| import 'package:analysis_server/lsp_protocol/protocol_generated.dart' as lsp; |
| import 'package:analysis_server/lsp_protocol/protocol_generated.dart'; |
| import 'package:analysis_server/lsp_protocol/protocol_special.dart'; |
| import 'package:analysis_server/lsp_protocol/protocol_special.dart' as lsp; |
| import 'package:analysis_server/src/collections.dart'; |
| 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/lsp_analysis_server.dart' as lsp; |
| 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/search/workspace_symbols.dart' as server |
| show DeclarationKind; |
| import 'package:analyzer/dart/analysis/results.dart' as server; |
| import 'package:analyzer/error/error.dart' as server; |
| import 'package:analyzer/source/line_info.dart' as server; |
| import 'package:analyzer/source/source_range.dart' as server; |
| import 'package:analyzer/src/error/codes.dart'; |
| import 'package:analyzer/src/services/available_declarations.dart'; |
| import 'package:analyzer/src/services/available_declarations.dart' as dec; |
| import 'package:analyzer_plugin/protocol/protocol_common.dart' as plugin; |
| import 'package:analyzer_plugin/utilities/pair.dart'; |
| import 'package:collection/collection.dart'; |
| |
| const languageSourceName = 'dart'; |
| |
| final diagnosticTagsForErrorCode = <String, List<lsp.DiagnosticTag>>{ |
| _errorCode(HintCode.DEAD_CODE): [lsp.DiagnosticTag.Unnecessary], |
| _errorCode(HintCode.DEPRECATED_MEMBER_USE): [lsp.DiagnosticTag.Deprecated], |
| _errorCode(HintCode.DEPRECATED_MEMBER_USE_FROM_SAME_PACKAGE): [ |
| lsp.DiagnosticTag.Deprecated |
| ], |
| _errorCode(HintCode.DEPRECATED_MEMBER_USE_FROM_SAME_PACKAGE_WITH_MESSAGE): [ |
| lsp.DiagnosticTag.Deprecated |
| ], |
| _errorCode(HintCode.DEPRECATED_MEMBER_USE_WITH_MESSAGE): [ |
| lsp.DiagnosticTag.Deprecated |
| ], |
| }; |
| |
| /// 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<String, lsp.MarkupContent> asStringOrMarkupContent( |
| Set<lsp.MarkupKind>? preferredFormats, String content) { |
| return preferredFormats == null |
| ? lsp.Either2<String, lsp.MarkupContent>.t1(content) |
| : lsp.Either2<String, lsp.MarkupContent>.t2( |
| _asMarkup(preferredFormats, content)); |
| } |
| |
| /// Builds an LSP snippet string with supplied ranges as tabstops. |
| String buildSnippetStringWithTabStops( |
| String? text, |
| List<int>? offsetLengthPairs, |
| ) { |
| text ??= ''; |
| offsetLengthPairs ??= const []; |
| |
| // Snippets syntax is documented in the LSP spec: |
| // https://microsoft.github.io/language-server-protocol/specifications/specification-current/#snippet_syntax |
| // |
| // $1, $2, etc. are used for tab stops and ${1:foo} inserts a placeholder of foo. |
| |
| final output = []; |
| var offset = 0; |
| |
| // When there's only a single tabstop, it should be ${0} as this is treated |
| // specially as the final cursor position (if we use 1, the editor will insert |
| // a 0 at the end of the string which is not what we expect). |
| // When there are multiple, start with ${1} since these are placeholders the |
| // user can tab through and the editor-inserted ${0} at the end is expected. |
| var tabStopNumber = offsetLengthPairs.length <= 2 ? 0 : 1; |
| |
| for (var i = 0; i < offsetLengthPairs.length; i += 2) { |
| final pairOffset = offsetLengthPairs[i]; |
| final pairLength = offsetLengthPairs[i + 1]; |
| |
| // Add any text that came before this tabstop to the result. |
| output.add(escapeSnippetString(text.substring(offset, pairOffset))); |
| |
| // Add this tabstop |
| final tabStopText = escapeSnippetString( |
| text.substring(pairOffset, pairOffset + pairLength)); |
| output.add('\${${tabStopNumber++}:$tabStopText}'); |
| |
| offset = pairOffset + pairLength; |
| } |
| |
| // Add any remaining text that was after the last tabstop. |
| output.add(escapeSnippetString(text.substring(offset))); |
| |
| return output.join(''); |
| } |
| |
| /// Creates a [lsp.WorkspaceEdit] from simple [server.SourceFileEdit]s. |
| /// |
| /// 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( |
| lsp.LspAnalysisServer server, List<server.SourceFileEdit> edits) { |
| return toWorkspaceEdit( |
| // Client capabilities are always available after initialization. |
| server.clientCapabilities!, |
| edits |
| .map((e) => FileEditInformation( |
| server.getVersionedDocumentIdentifier(e.file), |
| // We should never produce edits for a file with no LineInfo. |
| server.getLineInfo(e.file)!, |
| 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(String oldPath, String newPath) { |
| final changes = |
| <Either4<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>>[]; |
| |
| final rename = RenameFile( |
| oldUri: Uri.file(oldPath).toString(), |
| newUri: Uri.file(newPath).toString(), |
| ); |
| |
| final renameUnion = |
| Either4<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>.t3(rename); |
| |
| changes.add(renameUnion); |
| |
| final edit = WorkspaceEdit( |
| documentChanges: Either2< |
| List<TextDocumentEdit>, |
| List< |
| Either4<TextDocumentEdit, CreateFile, RenameFile, |
| DeleteFile>>>.t2(changes)); |
| return edit; |
| } |
| |
| /// Creates a [lsp.WorkspaceEdit] from a [server.SourceChange] that can include |
| /// experimental [server.SnippetTextEdit]s if the client has indicated support |
| /// for these in the experimental section of their client capabilities. |
| /// |
| /// 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( |
| lsp.LspAnalysisServer server, server.SourceChange change) { |
| // In order to return snippets, we must ensure we are only modifying a single |
| // existing file with a single edit and that there is a linked edit group with |
| // only one position and no suggestions. |
| if (!server.clientCapabilities!.experimentalSnippetTextEdit || |
| change.edits.length != 1 || |
| change.edits.first.fileStamp == -1 || // new file |
| change.edits.first.edits.length != 1 || |
| change.linkedEditGroups.isEmpty || |
| change.linkedEditGroups.first.positions.length != 1 || |
| change.linkedEditGroups.first.suggestions.isNotEmpty) { |
| return createPlainWorkspaceEdit(server, change.edits); |
| } |
| |
| // Additionally, the selection must fall within the edit offset. |
| final edit = change.edits.first.edits.first; |
| final selectionOffset = change.linkedEditGroups.first.positions.first.offset; |
| final selectionLength = change.linkedEditGroups.first.length; |
| |
| if (selectionOffset < edit.offset || |
| selectionOffset + selectionLength > edit.offset + edit.length) { |
| return createPlainWorkspaceEdit(server, change.edits); |
| } |
| |
| return toWorkspaceEdit( |
| server.clientCapabilities!, |
| change.edits |
| .map((e) => FileEditInformation( |
| server.getVersionedDocumentIdentifier(e.file), |
| // We should never produce edits for a file with no LineInfo. |
| server.getLineInfo(e.file)!, |
| e.edits, |
| selectionOffsetRelative: selectionOffset - edit.offset, |
| selectionLength: selectionLength, |
| newFile: e.fileStamp == -1, |
| )) |
| .toList()); |
| } |
| |
| lsp.CompletionItemKind? declarationKindToCompletionItemKind( |
| Set<lsp.CompletionItemKind> supportedCompletionKinds, |
| dec.DeclarationKind kind, |
| ) { |
| bool isSupported(lsp.CompletionItemKind kind) => |
| supportedCompletionKinds.contains(kind); |
| |
| List<lsp.CompletionItemKind> getKindPreferences() { |
| switch (kind) { |
| case dec.DeclarationKind.CLASS: |
| case dec.DeclarationKind.CLASS_TYPE_ALIAS: |
| case dec.DeclarationKind.MIXIN: |
| return const [lsp.CompletionItemKind.Class]; |
| case dec.DeclarationKind.CONSTRUCTOR: |
| return const [lsp.CompletionItemKind.Constructor]; |
| case dec.DeclarationKind.ENUM: |
| return const [lsp.CompletionItemKind.Enum]; |
| case dec.DeclarationKind.ENUM_CONSTANT: |
| return const [ |
| lsp.CompletionItemKind.EnumMember, |
| lsp.CompletionItemKind.Enum, |
| ]; |
| case dec.DeclarationKind.FUNCTION: |
| return const [lsp.CompletionItemKind.Function]; |
| case dec.DeclarationKind.FUNCTION_TYPE_ALIAS: |
| return const [lsp.CompletionItemKind.Class]; |
| case dec.DeclarationKind.GETTER: |
| return const [lsp.CompletionItemKind.Property]; |
| case dec.DeclarationKind.SETTER: |
| return const [lsp.CompletionItemKind.Property]; |
| case dec.DeclarationKind.TYPE_ALIAS: |
| return const [lsp.CompletionItemKind.Class]; |
| case dec.DeclarationKind.VARIABLE: |
| return const [lsp.CompletionItemKind.Variable]; |
| default: |
| return const []; |
| } |
| } |
| |
| return getKindPreferences().firstWhereOrNull(isSupported); |
| } |
| |
| 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.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.CompletionItem declarationToCompletionItem( |
| LspClientCapabilities capabilities, |
| String file, |
| int offset, |
| server.IncludedSuggestionSet includedSuggestionSet, |
| Library library, |
| Map<String, int> tagBoosts, |
| server.LineInfo lineInfo, |
| dec.Declaration declaration, |
| int replacementOffset, |
| int insertLength, |
| int replacementLength, { |
| required bool includeCommitCharacters, |
| required bool completeFunctionCalls, |
| }) { |
| final supportsSnippets = capabilities.completionSnippets; |
| final parent = declaration.parent; |
| |
| String completion; |
| switch (declaration.kind) { |
| case DeclarationKind.ENUM_CONSTANT: |
| completion = '${parent!.name}.${declaration.name}'; |
| break; |
| case DeclarationKind.GETTER: |
| case DeclarationKind.FIELD: |
| completion = parent != null && parent.name.isNotEmpty |
| ? '${parent.name}.${declaration.name}' |
| : declaration.name; |
| break; |
| case DeclarationKind.CONSTRUCTOR: |
| completion = parent!.name; |
| if (declaration.name.isNotEmpty) { |
| completion += '.${declaration.name}'; |
| } |
| break; |
| default: |
| completion = declaration.name; |
| break; |
| } |
| // By default, label is the same as the completion text, but may be added to |
| // later (parens/snippets). |
| var label = completion; |
| |
| // isCallable is used to suffix the label with parens so it's clear the item |
| // is callable. |
| final declarationKind = declaration.kind; |
| final isCallable = declarationKind == DeclarationKind.CONSTRUCTOR || |
| declarationKind == DeclarationKind.FUNCTION || |
| declarationKind == DeclarationKind.METHOD; |
| |
| if (isCallable) { |
| label += declaration.parameterNames?.isNotEmpty ?? false ? '(…)' : '()'; |
| } |
| |
| final insertTextInfo = _buildInsertText( |
| supportsSnippets: supportsSnippets, |
| includeCommitCharacters: includeCommitCharacters, |
| completeFunctionCalls: completeFunctionCalls, |
| isCallable: isCallable, |
| // For SuggestionSets, we don't have a CompletionKind to check if it's |
| // an invocation, but since they do not show in show/hide combinators |
| // we can assume if an item is callable it's probably being used in a context |
| // that can invoke it. |
| isInvocation: isCallable, |
| defaultArgumentListString: declaration.defaultArgumentListString, |
| defaultArgumentListTextRanges: declaration.defaultArgumentListTextRanges, |
| completion: completion, |
| selectionOffset: 0, |
| selectionLength: 0, |
| ); |
| final insertText = insertTextInfo.first; |
| final insertTextFormat = insertTextInfo.last; |
| final isMultilineCompletion = insertText.contains('\n'); |
| |
| final supportsDeprecatedFlag = capabilities.completionDeprecatedFlag; |
| final supportsDeprecatedTag = capabilities.completionItemTags |
| .contains(lsp.CompletionItemTag.Deprecated); |
| final supportsAsIsInsertMode = |
| capabilities.completionInsertTextModes.contains(InsertTextMode.asIs); |
| |
| final completionKind = declarationKindToCompletionItemKind( |
| capabilities.completionItemKinds, declaration.kind); |
| |
| var relevanceBoost = 0; |
| declaration.relevanceTags |
| .forEach((t) => relevanceBoost = max(relevanceBoost, tagBoosts[t] ?? 0)); |
| final itemRelevance = includedSuggestionSet.relevance + relevanceBoost; |
| |
| // 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 && declaration.isDeprecated) |
| lsp.CompletionItemTag.Deprecated |
| ]), |
| commitCharacters: |
| includeCommitCharacters ? lsp.dartCompletionCommitCharacters : null, |
| detail: getDeclarationCompletionDetail(declaration, completionKind, |
| supportsDeprecatedFlag || supportsDeprecatedTag), |
| deprecated: |
| supportsDeprecatedFlag && declaration.isDeprecated ? true : null, |
| sortText: relevanceToSortText(itemRelevance), |
| filterText: completion != label |
| ? completion |
| : null, // filterText uses label if not set |
| insertText: insertText != label |
| ? insertText |
| : null, // insertText uses label if not set |
| insertTextFormat: insertTextFormat != lsp.InsertTextFormat.PlainText |
| ? insertTextFormat |
| : null, // Defaults to PlainText if not supplied |
| insertTextMode: supportsAsIsInsertMode && isMultilineCompletion |
| ? InsertTextMode.asIs |
| : null, |
| // data, used for completionItem/resolve. |
| data: lsp.DartCompletionItemResolutionInfo( |
| file: file, |
| offset: offset, |
| libId: includedSuggestionSet.id, |
| displayUri: includedSuggestionSet.displayUri ?? library.uri.toString(), |
| rOffset: replacementOffset, |
| iLength: insertLength, |
| rLength: replacementLength), |
| ); |
| } |
| |
| 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.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.Module]; |
| 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.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); |
| } |
| |
| /// Escapes a string to be used in an LSP edit that uses Snippet mode. |
| /// |
| /// Snippets can contain special markup like `${a:b}` so some characters need |
| /// escaping (according to the LSP spec, those are `$`, `}` and `\`). |
| String escapeSnippetString(String input) => input.replaceAllMapped( |
| RegExp(r'[$}\\]'), // Replace any of $ } \ |
| (c) => '\\${c[0]}', // Prefix with a backslash |
| ); |
| |
| String? getCompletionDetail( |
| server.CompletionSuggestion suggestion, |
| lsp.CompletionItemKind? completionKind, |
| bool supportsDeprecated, |
| ) { |
| final element = suggestion.element; |
| final hasElement = element != null; |
| final parameters = element?.parameters; |
| final returnType = element?.returnType; |
| final parameterType = suggestion.parameterType; |
| final hasParameters = |
| hasElement && parameters != null && parameters.isNotEmpty; |
| final hasReturnType = |
| hasElement && returnType != null && returnType.isNotEmpty; |
| final hasParameterType = parameterType != null && parameterType.isNotEmpty; |
| |
| final prefix = |
| supportsDeprecated || !suggestion.isDeprecated ? '' : '(Deprecated) '; |
| |
| if (completionKind == lsp.CompletionItemKind.Property) { |
| // Setters appear as methods with one arg but they also cause getters to not |
| // appear in the completion list, so displaying them as setters is misleading. |
| // To avoid this, always show only the return type, whether it's a getter |
| // or a setter. |
| return prefix + |
| (element?.kind == server.ElementKind.GETTER |
| ? (returnType ?? '') |
| // Don't assume setters always have parameters |
| // See https://github.com/dart-lang/sdk/issues/27747 |
| : parameters != null && parameters.isNotEmpty |
| // Extract the type part from '(MyType value)` |
| ? parameters.substring(1, parameters.lastIndexOf(' ')) |
| : ''); |
| } else if (hasParameters && hasReturnType) { |
| return '$prefix$parameters → $returnType'; |
| } else if (hasReturnType) { |
| return '$prefix$returnType'; |
| } else if (hasParameterType) { |
| return '$prefix$parameterType'; |
| } else { |
| return prefix.isNotEmpty ? prefix : null; |
| } |
| } |
| |
| String? getDeclarationCompletionDetail( |
| dec.Declaration declaration, |
| lsp.CompletionItemKind? completionKind, |
| bool supportsDeprecated, |
| ) { |
| final parameters = declaration.parameters; |
| final hasParameters = parameters != null && parameters.isNotEmpty; |
| final returnType = declaration.returnType; |
| final hasReturnType = returnType != null && returnType.isNotEmpty; |
| |
| final prefix = |
| supportsDeprecated || !declaration.isDeprecated ? '' : '(Deprecated) '; |
| |
| if (completionKind == lsp.CompletionItemKind.Property) { |
| // Setters appear as methods with one arg but they also cause getters to not |
| // appear in the completion list, so displaying them as setters is misleading. |
| // To avoid this, always show only the return type, whether it's a getter |
| // or a setter. |
| var suffix = ''; |
| if (declaration.kind == dec.DeclarationKind.GETTER) { |
| suffix = declaration.returnType ?? ''; |
| } else { |
| // Don't assume setters always have parameters |
| // See https://github.com/dart-lang/sdk/issues/27747 |
| if (parameters != null && parameters.isNotEmpty) { |
| // Extract the type part from `(MyType value)`, if there is a type. |
| var spaceIndex = parameters.lastIndexOf(' '); |
| if (spaceIndex > 0) { |
| suffix = parameters.substring(1, spaceIndex); |
| } |
| } |
| } |
| return prefix + suffix; |
| } else if (hasParameters && hasReturnType) { |
| return '$prefix${declaration.parameters} → ${declaration.returnType}'; |
| } else if (hasReturnType) { |
| return '$prefix${declaration.returnType}'; |
| } else { |
| return prefix.isNotEmpty ? prefix : null; |
| } |
| } |
| |
| List<lsp.DiagnosticTag>? getDiagnosticTags( |
| Set<lsp.DiagnosticTag>? supportedTags, plugin.AnalysisError error) { |
| if (supportedTags == null) { |
| return null; |
| } |
| |
| final tags = diagnosticTagsForErrorCode[error.code] |
| ?.where(supportedTags.contains) |
| .toList(); |
| |
| return tags != null && tags.isNotEmpty ? tags : null; |
| } |
| |
| bool isDartDocument(lsp.TextDocumentIdentifier? doc) => |
| doc?.uri.endsWith('.dart') ?? false; |
| |
| /// 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. |
| final changes = |
| <Either4<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>>[]; |
| |
| for (final edit in edits) { |
| // Flatten the Either into just the Union side to get a flat list. |
| final flatResourceChanges = edit.documentChanges!.map( |
| (edits) => edits.map((e) => |
| Either4<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>.t1(e)), |
| (resources) => resources, |
| ); |
| changes.addAll(flatResourceChanges); |
| } |
| |
| return WorkspaceEdit( |
| documentChanges: Either2< |
| List<TextDocumentEdit>, |
| List< |
| Either4<TextDocumentEdit, CreateFile, RenameFile, |
| DeleteFile>>>.t2(changes)); |
| } |
| |
| lsp.Location navigationTargetToLocation( |
| String targetFilePath, |
| server.NavigationTarget target, |
| server.LineInfo targetLineInfo, |
| ) { |
| return lsp.Location( |
| uri: Uri.file(targetFilePath).toString(), |
| range: toRange(targetLineInfo, target.offset, target.length), |
| ); |
| } |
| |
| lsp.LocationLink? navigationTargetToLocationLink( |
| server.NavigationRegion region, |
| server.LineInfo regionLineInfo, |
| String targetFilePath, |
| server.NavigationTarget target, |
| server.LineInfo targetLineInfo, |
| ) { |
| final nameRange = toRange(targetLineInfo, target.offset, target.length); |
| final codeOffset = target.codeOffset; |
| final codeLength = target.codeLength; |
| final codeRange = codeOffset != null && codeLength != null |
| ? toRange(targetLineInfo, codeOffset, codeLength) |
| : nameRange; |
| |
| return lsp.LocationLink( |
| originSelectionRange: toRange(regionLineInfo, region.offset, region.length), |
| targetUri: Uri.file(targetFilePath).toString(), |
| targetRange: codeRange, |
| targetSelectionRange: nameRange, |
| ); |
| } |
| |
| /// Returns the file system path for a TextDocumentIdentifier. |
| ErrorOr<String> pathOfDoc(lsp.TextDocumentIdentifier doc) => |
| pathOfUri(Uri.tryParse(doc.uri)); |
| |
| /// Returns the file system path for a TextDocumentItem. |
| ErrorOr<String> pathOfDocItem(lsp.TextDocumentItem doc) => |
| pathOfUri(Uri.tryParse(doc.uri)); |
| |
| /// Returns the file system path for a file URI. |
| ErrorOr<String> pathOfUri(Uri? uri) { |
| if (uri == null) { |
| return ErrorOr<String>.error(ResponseError( |
| code: lsp.ServerErrorCodes.InvalidFilePath, |
| message: 'Document URI was not supplied', |
| )); |
| } |
| final isValidFileUri = uri.isScheme('file'); |
| if (!isValidFileUri) { |
| return ErrorOr<String>.error(ResponseError( |
| code: lsp.ServerErrorCodes.InvalidFilePath, |
| message: 'URI was not a valid file:// URI', |
| data: uri.toString(), |
| )); |
| } |
| try { |
| return ErrorOr<String>.success(uri.toFilePath()); |
| } catch (e) { |
| // Even if tryParse() works and file == scheme, toFilePath() can throw on |
| // Windows if there are invalid characters. |
| return ErrorOr<String>.error(ResponseError( |
| code: lsp.ServerErrorCodes.InvalidFilePath, |
| message: 'File URI did not contain a valid file path', |
| data: uri.toString())); |
| } |
| } |
| |
| lsp.Diagnostic pluginToDiagnostic( |
| server.LineInfo Function(String) getLineInfo, |
| plugin.AnalysisError error, { |
| required Set<lsp.DiagnosticTag>? supportedTags, |
| required bool clientSupportsCodeDescription, |
| }) { |
| List<lsp.DiagnosticRelatedInformation>? relatedInformation; |
| final contextMessages = error.contextMessages; |
| if (contextMessages != null && contextMessages.isNotEmpty) { |
| relatedInformation = contextMessages |
| .map((message) => |
| pluginToDiagnosticRelatedInformation(getLineInfo, message)) |
| .whereNotNull() |
| .toList(); |
| } |
| |
| var message = error.message; |
| if (error.correction != null) { |
| message = '$message\n${error.correction}'; |
| } |
| |
| var lineInfo = getLineInfo(error.location.file); |
| var documentationUrl = error.url; |
| return lsp.Diagnostic( |
| range: toRange(lineInfo, error.location.offset, error.location.length), |
| 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)serialisation). |
| codeDescription: clientSupportsCodeDescription && documentationUrl != null |
| ? CodeDescription(href: documentationUrl) |
| : null, |
| ); |
| } |
| |
| lsp.DiagnosticRelatedInformation? pluginToDiagnosticRelatedInformation( |
| server.LineInfo? Function(String) getLineInfo, |
| plugin.DiagnosticMessage message) { |
| final file = message.location.file; |
| final 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.file(file).toString(), |
| range: toRange( |
| lineInfo, |
| message.location.offset, |
| message.location.length, |
| ), |
| ), |
| message: message.message); |
| } |
| |
| lsp.DiagnosticSeverity pluginToDiagnosticSeverity( |
| plugin.AnalysisErrorSeverity severity) { |
| switch (severity) { |
| case plugin.AnalysisErrorSeverity.ERROR: |
| return lsp.DiagnosticSeverity.Error; |
| case plugin.AnalysisErrorSeverity.WARNING: |
| return lsp.DiagnosticSeverity.Warning; |
| case plugin.AnalysisErrorSeverity.INFO: |
| return lsp.DiagnosticSeverity.Information; |
| // Note: LSP also supports "Hint", but they won't render in things like the |
| // VS Code errors list as they're apparently intended to communicate |
| // non-visible diagnostics back (for example, if you wanted to grey out |
| // unreachable code without producing an item in the error list). |
| default: |
| throw 'Unknown AnalysisErrorSeverity: $severity'; |
| } |
| } |
| |
| /// 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. |
| /// |
| /// 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) => (9999999 - relevance).toString(); |
| |
| lsp.Location? searchResultToLocation( |
| server.SearchResult result, server.LineInfo? lineInfo) { |
| final location = result.location; |
| |
| if (lineInfo == null) { |
| return null; |
| } |
| |
| return lsp.Location( |
| uri: Uri.file(result.location.file).toString(), |
| range: toRange(lineInfo, location.offset, location.length), |
| ); |
| } |
| |
| 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()); |
| |
| // 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, |
| int replacementOffset, |
| int insertLength, |
| int replacementLength, { |
| required bool includeCommitCharacters, |
| required bool completeFunctionCalls, |
| CompletionItemResolutionInfo? resolutionData, |
| }) { |
| // Build separate display and filter labels. Displayed labels may have additional |
| // info appended (for example '(...)' on callables) that should not be included |
| // in filterText. |
| var label = suggestion.displayText ?? suggestion.completion; |
| final filterText = label; |
| |
| // Trim any trailing comma from the (displayed) label. |
| if (label.endsWith(',')) { |
| label = label.substring(0, label.length - 1); |
| } |
| |
| // 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. |
| final elementKind = suggestion.element?.kind; |
| final isCallable = elementKind == server.ElementKind.CONSTRUCTOR || |
| elementKind == server.ElementKind.FUNCTION || |
| elementKind == server.ElementKind.METHOD; |
| final isInvocation = |
| suggestion.kind == server.CompletionSuggestionKind.INVOCATION; |
| |
| if (suggestion.displayText == null && isCallable) { |
| label += suggestion.parameterNames?.isNotEmpty ?? false ? '(…)' : '()'; |
| } |
| |
| final supportsCompletionDeprecatedFlag = |
| capabilities.completionDeprecatedFlag; |
| final supportsDeprecatedTag = capabilities.completionItemTags |
| .contains(lsp.CompletionItemTag.Deprecated); |
| final formats = capabilities.completionDocumentationFormats; |
| final supportsSnippets = capabilities.completionSnippets; |
| final supportsInsertReplace = capabilities.insertReplaceCompletionRanges; |
| final supportsAsIsInsertMode = |
| capabilities.completionInsertTextModes.contains(InsertTextMode.asIs); |
| |
| final element = suggestion.element; |
| final completionKind = element != null |
| ? elementKindToCompletionItemKind( |
| capabilities.completionItemKinds, element.kind) |
| : suggestionKindToCompletionItemKind( |
| capabilities.completionItemKinds, suggestion.kind, label); |
| |
| final insertTextInfo = _buildInsertText( |
| supportsSnippets: supportsSnippets, |
| includeCommitCharacters: includeCommitCharacters, |
| completeFunctionCalls: completeFunctionCalls, |
| isCallable: isCallable, |
| isInvocation: isInvocation, |
| defaultArgumentListString: suggestion.defaultArgumentListString, |
| defaultArgumentListTextRanges: suggestion.defaultArgumentListTextRanges, |
| completion: suggestion.completion, |
| selectionOffset: suggestion.selectionOffset, |
| selectionLength: suggestion.selectionLength, |
| ); |
| final insertText = insertTextInfo.first; |
| final insertTextFormat = insertTextInfo.last; |
| final isMultilineCompletion = insertText.contains('\n'); |
| |
| var cleanedDoc = cleanDartdoc(suggestion.docComplete); |
| var detail = getCompletionDetail(suggestion, completionKind, |
| supportsCompletionDeprecatedFlag || supportsDeprecatedTag); |
| |
| // 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. |
| final labelMatch = cleanedDoc != null |
| ? _upgradableDocCompletePattern.firstMatch(cleanedDoc) |
| : null; |
| if (labelMatch != null) { |
| cleanedDoc = null; |
| detail = labelMatch.group(1); |
| } |
| |
| // 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 |
| ]), |
| commitCharacters: |
| includeCommitCharacters ? dartCompletionCommitCharacters : null, |
| data: resolutionData, |
| detail: detail, |
| documentation: cleanedDoc != null |
| ? asStringOrMarkupContent(formats, cleanedDoc) |
| : null, |
| deprecated: supportsCompletionDeprecatedFlag && suggestion.isDeprecated |
| ? true |
| : null, |
| sortText: relevanceToSortText(suggestion.relevance), |
| filterText: filterText != label |
| ? filterText |
| : null, // filterText uses label if not set |
| insertText: insertText != label |
| ? insertText |
| : null, // insertText uses label if not set |
| insertTextFormat: insertTextFormat != lsp.InsertTextFormat.PlainText |
| ? insertTextFormat |
| : null, // Defaults to PlainText if not supplied |
| insertTextMode: supportsAsIsInsertMode && isMultilineCompletion |
| ? InsertTextMode.asIs |
| : null, |
| textEdit: supportsInsertReplace && insertLength != replacementLength |
| ? Either2<TextEdit, InsertReplaceEdit>.t2( |
| InsertReplaceEdit( |
| insert: toRange(lineInfo, replacementOffset, insertLength), |
| replace: toRange(lineInfo, replacementOffset, replacementLength), |
| newText: insertText, |
| ), |
| ) |
| : Either2<TextEdit, InsertReplaceEdit>.t1( |
| TextEdit( |
| range: toRange(lineInfo, replacementOffset, replacementLength), |
| newText: insertText, |
| ), |
| ), |
| ); |
| } |
| |
| lsp.Diagnostic toDiagnostic( |
| server.ResolvedUnitResult result, |
| server.AnalysisError error, { |
| required Set<lsp.DiagnosticTag> supportedTags, |
| required bool clientSupportsCodeDescription, |
| }) { |
| return pluginToDiagnostic( |
| (_) => result.lineInfo, |
| server.newAnalysisError_fromEngine(result, error), |
| supportedTags: supportedTags, |
| clientSupportsCodeDescription: clientSupportsCodeDescription, |
| ); |
| } |
| |
| lsp.Element toElement(server.LineInfo lineInfo, server.Element element) { |
| final 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) { |
| final attributes = outline.attributes; |
| final dartElement = outline.dartElement; |
| final children = outline.children; |
| |
| return lsp.FlutterOutline( |
| kind: outline.kind.name, |
| label: outline.label, |
| className: outline.className, |
| variableName: outline.variableName, |
| attributes: attributes != null |
| ? attributes |
| .map((attribute) => toFlutterOutlineAttribute(lineInfo, attribute)) |
| .toList() |
| : null, |
| dartElement: dartElement != null ? toElement(lineInfo, dartElement) : null, |
| range: toRange(lineInfo, outline.offset, outline.length), |
| codeRange: toRange(lineInfo, outline.codeOffset, outline.codeLength), |
| children: children != null |
| ? children.map((c) => toFlutterOutline(lineInfo, c)).toList() |
| : null, |
| ); |
| } |
| |
| lsp.FlutterOutlineAttribute toFlutterOutlineAttribute( |
| server.LineInfo lineInfo, server.FlutterOutlineAttribute attribute) { |
| final valueLocation = attribute.valueLocation; |
| return lsp.FlutterOutlineAttribute( |
| name: attribute.name, |
| label: attribute.label, |
| valueRange: valueLocation != null |
| ? toRange(lineInfo, valueLocation.offset, valueLocation.length) |
| : null); |
| } |
| |
| lsp.FoldingRange toFoldingRange( |
| server.LineInfo lineInfo, server.FoldingRegion region) { |
| final range = toRange(lineInfo, region.offset, region.length); |
| return lsp.FoldingRange( |
| startLine: range.start.line, |
| startCharacter: range.start.character, |
| endLine: range.end.line, |
| endCharacter: range.end.character, |
| kind: toFoldingRangeKind(region.kind)); |
| } |
| |
| 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.DocumentHighlight> toHighlights( |
| server.LineInfo lineInfo, server.Occurrences occurrences) { |
| return occurrences.offsets |
| .map((offset) => lsp.DocumentHighlight( |
| range: toRange(lineInfo, offset, occurrences.length))) |
| .toList(); |
| } |
| |
| lsp.Location toLocation(server.Location location, server.LineInfo lineInfo) => |
| lsp.Location( |
| uri: Uri.file(location.file).toString(), |
| range: toRange( |
| lineInfo, |
| location.offset, |
| location.length, |
| ), |
| ); |
| |
| ErrorOr<int> toOffset( |
| server.LineInfo lineInfo, |
| lsp.Position pos, { |
| failureIsCritial = false, |
| }) { |
| // line is zero-based so cannot equal lineCount |
| if (pos.line >= lineInfo.lineCount) { |
| return ErrorOr<int>.error(lsp.ResponseError( |
| code: failureIsCritial |
| ? 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) { |
| final 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 != null |
| ? children.map((c) => toOutline(lineInfo, c)).toList() |
| : null, |
| ); |
| } |
| |
| 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) { |
| final start = lineInfo.getLocation(offset); |
| final end = lineInfo.getLocation(offset + length); |
| |
| return lsp.Range( |
| start: toPosition(start), |
| end: toPosition(end), |
| ); |
| } |
| |
| lsp.SignatureHelp toSignatureHelp(Set<lsp.MarkupKind>? preferredFormats, |
| server.AnalysisGetSignatureResult 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(server.ParameterInfo p) { |
| final def = p.defaultValue != null ? ' = ${p.defaultValue}' : ''; |
| final prefix = |
| p.kind == server.ParameterKind.REQUIRED_NAMED ? 'required ' : ''; |
| return '$prefix${p.type} ${p.name}$def'; |
| } |
| |
| /// Gets the full signature label in the form |
| /// foo(String s, int i, bool a = true) |
| String getSignatureLabel(server.AnalysisGetSignatureResult resp) { |
| final positionalRequired = signature.parameters |
| .where((p) => p.kind == server.ParameterKind.REQUIRED_POSITIONAL) |
| .toList(); |
| final positionalOptional = signature.parameters |
| .where((p) => p.kind == server.ParameterKind.OPTIONAL_POSITIONAL) |
| .toList(); |
| final named = signature.parameters |
| .where((p) => |
| p.kind == server.ParameterKind.OPTIONAL_NAMED || |
| p.kind == server.ParameterKind.REQUIRED_NAMED) |
| .toList(); |
| final params = []; |
| if (positionalRequired.isNotEmpty) { |
| params.add(positionalRequired.map(getParamLabel).join(', ')); |
| } |
| if (positionalOptional.isNotEmpty) { |
| params.add('[' + positionalOptional.map(getParamLabel).join(', ') + ']'); |
| } |
| if (named.isNotEmpty) { |
| params.add('{' + named.map(getParamLabel).join(', ') + '}'); |
| } |
| return '${resp.name}(${params.join(", ")})'; |
| } |
| |
| lsp.ParameterInformation toParameterInfo(server.ParameterInfo 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)); |
| } |
| |
| final cleanedDoc = cleanDartdoc(signature.dartdoc); |
| |
| return lsp.SignatureHelp( |
| signatures: [ |
| lsp.SignatureInformation( |
| label: getSignatureLabel(signature), |
| documentation: cleanedDoc != null |
| ? asStringOrMarkupContent(preferredFormats, cleanedDoc) |
| : null, |
| parameters: signature.parameters.map(toParameterInfo).toList(), |
| ), |
| ], |
| activeSignature: 0, // activeSignature |
| // TODO(dantup): The LSP spec says this value will default to 0 if it's |
| // not supplied or outside of the value range. However, setting -1 results |
| // in no parameters being selected in VS Code, whereas null/0 will select the first. |
| // We'd like for none to be selected (since we don't support this yet) so |
| // we send -1. I've made a request for LSP to support not selecting a parameter |
| // (because you could also be on param 5 of an invalid call to a function |
| // taking only 3 arguments) here: |
| // https://github.com/Microsoft/language-server-protocol/issues/456#issuecomment-452318297 |
| activeParameter: -1, // activeParameter |
| ); |
| } |
| |
| lsp.SnippetTextEdit toSnippetTextEdit( |
| LspClientCapabilities capabilities, |
| server.LineInfo lineInfo, |
| server.SourceEdit edit, |
| 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]), |
| ); |
| } |
| |
| 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. |
| final start = toOffset(lineInfo, range.start); |
| final end = toOffset(lineInfo, range.end); |
| if (start.isError) { |
| return failure(start); |
| } |
| if (end.isError) { |
| return failure(end); |
| } |
| |
| final startOffset = start.result; |
| final endOffset = end.result; |
| |
| return success(server.SourceRange(startOffset, endOffset - startOffset)); |
| } |
| |
| ErrorOr<server.SourceRange?> toSourceRangeNullable( |
| server.LineInfo lineInfo, Range? range) => |
| range != null ? toSourceRange(lineInfo, range) : success(null); |
| |
| lsp.TextDocumentEdit toTextDocumentEdit( |
| LspClientCapabilities capabilities, FileEditInformation edit) { |
| return lsp.TextDocumentEdit( |
| textDocument: edit.doc, |
| edits: edit.edits |
| .map((e) => toTextDocumentEditEdit(capabilities, edit.lineInfo, e, |
| selectionOffsetRelative: edit.selectionOffsetRelative, |
| selectionLength: edit.selectionLength)) |
| .toList()); |
| } |
| |
| Either3<lsp.SnippetTextEdit, lsp.AnnotatedTextEdit, lsp.TextEdit> |
| toTextDocumentEditEdit( |
| LspClientCapabilities capabilities, |
| server.LineInfo lineInfo, |
| server.SourceEdit edit, { |
| int? selectionOffsetRelative, |
| int? selectionLength, |
| }) { |
| if (!capabilities.experimentalSnippetTextEdit || |
| selectionOffsetRelative == null) { |
| return Either3<lsp.SnippetTextEdit, lsp.AnnotatedTextEdit, lsp.TextEdit>.t3( |
| toTextEdit(lineInfo, edit)); |
| } |
| return Either3<lsp.SnippetTextEdit, lsp.AnnotatedTextEdit, lsp.TextEdit>.t1( |
| toSnippetTextEdit(capabilities, lineInfo, edit, selectionOffsetRelative, |
| selectionLength)); |
| } |
| |
| lsp.TextEdit toTextEdit(server.LineInfo lineInfo, server.SourceEdit edit) { |
| return lsp.TextEdit( |
| range: toRange(lineInfo, edit.offset, edit.length), |
| newText: edit.replacement, |
| ); |
| } |
| |
| lsp.WorkspaceEdit toWorkspaceEdit( |
| LspClientCapabilities capabilities, |
| List<FileEditInformation> edits, |
| ) { |
| final supportsDocumentChanges = capabilities.documentChanges; |
| if (supportsDocumentChanges) { |
| final supportsCreate = capabilities.createResourceOperations; |
| final changes = < |
| Either4<lsp.TextDocumentEdit, lsp.CreateFile, lsp.RenameFile, |
| lsp.DeleteFile>>[]; |
| |
| // Convert each SourceEdit to either a TextDocumentEdit or a |
| // CreateFile + a TextDocumentEdit depending on whether it's a new |
| // file. |
| for (final edit in edits) { |
| if (supportsCreate && edit.newFile) { |
| final create = lsp.CreateFile(uri: edit.doc.uri); |
| final createUnion = Either4<lsp.TextDocumentEdit, lsp.CreateFile, |
| lsp.RenameFile, lsp.DeleteFile>.t2(create); |
| changes.add(createUnion); |
| } |
| |
| final textDocEdit = toTextDocumentEdit(capabilities, edit); |
| final textDocEditUnion = Either4<lsp.TextDocumentEdit, lsp.CreateFile, |
| lsp.RenameFile, lsp.DeleteFile>.t1(textDocEdit); |
| changes.add(textDocEditUnion); |
| } |
| |
| return lsp.WorkspaceEdit( |
| documentChanges: Either2< |
| List<lsp.TextDocumentEdit>, |
| List< |
| Either4<lsp.TextDocumentEdit, lsp.CreateFile, lsp.RenameFile, |
| lsp.DeleteFile>>>.t2(changes)); |
| } else { |
| return lsp.WorkspaceEdit(changes: toWorkspaceEditChanges(edits)); |
| } |
| } |
| |
| Map<String, List<lsp.TextEdit>> toWorkspaceEditChanges( |
| List<FileEditInformation> edits) { |
| MapEntry<String, List<lsp.TextEdit>> createEdit(FileEditInformation file) { |
| final edits = |
| file.edits.map((edit) => toTextEdit(file.lineInfo, edit)).toList(); |
| return MapEntry(file.doc.uri, edits); |
| } |
| |
| return Map<String, List<lsp.TextEdit>>.fromEntries(edits.map(createEdit)); |
| } |
| |
| lsp.MarkupContent _asMarkup( |
| Set<lsp.MarkupKind> preferredFormats, String content) { |
| if (preferredFormats.isEmpty) { |
| preferredFormats.add(lsp.MarkupKind.Markdown); |
| } |
| |
| final supportsMarkdown = preferredFormats.contains(lsp.MarkupKind.Markdown); |
| final 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. |
| final format = supportsPlain && !supportsMarkdown |
| ? lsp.MarkupKind.PlainText |
| : lsp.MarkupKind.Markdown; |
| |
| return lsp.MarkupContent(kind: format, value: content); |
| } |
| |
| Pair<String, lsp.InsertTextFormat> _buildInsertText({ |
| required bool supportsSnippets, |
| required bool includeCommitCharacters, |
| required bool completeFunctionCalls, |
| required bool isCallable, |
| required bool isInvocation, |
| required String? defaultArgumentListString, |
| required List<int>? defaultArgumentListTextRanges, |
| 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 (!includeCommitCharacters && |
| completeFunctionCalls && |
| isCallable && |
| isInvocation) { |
| insertTextFormat = lsp.InsertTextFormat.Snippet; |
| final hasRequiredParameters = |
| (defaultArgumentListTextRanges?.length ?? 0) > 0; |
| final functionCallSuffix = hasRequiredParameters |
| ? buildSnippetStringWithTabStops( |
| defaultArgumentListString, |
| defaultArgumentListTextRanges, |
| ) |
| : '\${0:}'; // No required params still gets a tabstop in the parens. |
| insertText = '${escapeSnippetString(insertText)}($functionCallSuffix)'; |
| } else if (selectionOffset != 0 && |
| // We don't need a tabstop if the selection is the end of the string. |
| selectionOffset != completion.length) { |
| insertTextFormat = lsp.InsertTextFormat.Snippet; |
| insertText = buildSnippetStringWithTabStops( |
| completion, |
| [selectionOffset, selectionLength], |
| ); |
| } |
| } |
| |
| return Pair(insertText, insertTextFormat); |
| } |
| |
| String _errorCode(server.ErrorCode code) => code.name.toLowerCase(); |