[Analyzer] Support completeFunctionCalls for LSP SuggestionSet completions
Fixes https://github.com/Dart-Code/Dart-Code/issues/2992.
Change-Id: I95ee3735b7f10d9ad06c729c7b5e6aabeee005e0
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/175251
Reviewed-by: Brian Wilkerson <brianwilkerson@google.com>
Commit-Queue: Brian Wilkerson <brianwilkerson@google.com>
diff --git a/pkg/analysis_server/lib/src/lsp/handlers/handler_completion.dart b/pkg/analysis_server/lib/src/lsp/handlers/handler_completion.dart
index c0b5988..ba9c66d 100644
--- a/pkg/analysis_server/lib/src/lsp/handlers/handler_completion.dart
+++ b/pkg/analysis_server/lib/src/lsp/handlers/handler_completion.dart
@@ -367,6 +367,8 @@
// https://github.com/microsoft/vscode-languageserver-node/issues/673
includeCommitCharacters:
server.clientConfiguration.previewCommitCharacters,
+ completeFunctionCalls:
+ server.clientConfiguration.completeFunctionCalls,
));
results.addAll(setResults);
});
diff --git a/pkg/analysis_server/lib/src/lsp/handlers/handler_completion_resolve.dart b/pkg/analysis_server/lib/src/lsp/handlers/handler_completion_resolve.dart
index 4ea03d1..3157a79 100644
--- a/pkg/analysis_server/lib/src/lsp/handlers/handler_completion_resolve.dart
+++ b/pkg/analysis_server/lib/src/lsp/handlers/handler_completion_resolve.dart
@@ -72,8 +72,11 @@
);
}
+ // If filterText is different to the label, it's because label has parens/args
+ // appended and we should take the basic label. We cannot use insertText as
+ // it may include snippets, whereas filterText is always just the pure string.
+ var requestedName = item.filterText ?? item.label;
// The label might be `MyEnum.myValue`, but we import only `MyEnum`.
- var requestedName = item.insertText ?? item.label;
if (requestedName.contains('.')) {
requestedName = requestedName.substring(
0,
@@ -173,8 +176,6 @@
insertText: newInsertText,
insertTextFormat: item.insertTextFormat,
textEdit: TextEdit(
- // TODO(dantup): If `clientSupportsSnippets == true` then we should map
- // `selection` in to a snippet (see how Dart Code does this).
range: toRange(lineInfo, data.rOffset, data.rLength),
newText: newInsertText,
),
diff --git a/pkg/analysis_server/lib/src/lsp/mapping.dart b/pkg/analysis_server/lib/src/lsp/mapping.dart
index 145b8bf..6f0cd7b 100644
--- a/pkg/analysis_server/lib/src/lsp/mapping.dart
+++ b/pkg/analysis_server/lib/src/lsp/mapping.dart
@@ -34,6 +34,7 @@
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:meta/meta.dart';
const diagnosticTagsForErrorCode = <server.ErrorCode, List<lsp.DiagnosticTag>>{
@@ -223,43 +224,67 @@
int replacementOffset,
int replacementLength, {
@required bool includeCommitCharacters,
+ @required bool completeFunctionCalls,
}) {
- // Build display labels and text to insert. insertText and filterText may
- // differ from label (for ex. if the label includes things like (…)). If
- // either are missing then label will be used by the client.
- String label;
- String insertText;
- String filterText;
+ final supportsSnippets =
+ completionCapabilities?.completionItem?.snippetSupport == true;
+
+ String completion;
switch (declaration.kind) {
case DeclarationKind.ENUM_CONSTANT:
- label = '${declaration.parent.name}.${declaration.name}';
+ completion = '${declaration.parent.name}.${declaration.name}';
break;
case DeclarationKind.GETTER:
case DeclarationKind.FIELD:
- label = declaration.parent != null &&
+ completion = declaration.parent != null &&
declaration.parent.name != null &&
declaration.parent.name.isNotEmpty
? '${declaration.parent.name}.${declaration.name}'
: declaration.name;
break;
case DeclarationKind.CONSTRUCTOR:
- label = declaration.parent.name;
+ completion = declaration.parent.name;
if (declaration.name.isNotEmpty) {
- label += '.${declaration.name}';
+ completion += '.${declaration.name}';
}
- insertText = label;
- filterText = label;
- label += declaration.parameterNames?.isNotEmpty ?? false ? '(…)' : '()';
- break;
- case DeclarationKind.FUNCTION:
- label = declaration.name;
- insertText = label;
- filterText = label;
- label += declaration.parameterNames?.isNotEmpty ?? false ? '(…)' : '()';
break;
default:
- label = declaration.name;
+ 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 supportsDeprecatedFlag =
completionCapabilities?.completionItem?.deprecatedSupport == true;
@@ -302,12 +327,15 @@
// 10 -> 999990
// 1 -> 999999
sortText: (1000000 - itemRelevance).toString(),
- filterText: filterText != label
- ? filterText
+ 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
// data, used for completionItem/resolve.
data: lsp.DartCompletionItemResolutionInfo(
file: file,
@@ -797,8 +825,6 @@
// differ from label (for ex. if the label includes things like (…)). If
// either are missing then label will be used by the client.
var label = suggestion.displayText ?? suggestion.completion;
- var insertText = suggestion.completion;
- var filterText = suggestion.completion;
// Trim any trailing comma from the (displayed) label.
if (label.endsWith(',')) {
@@ -841,37 +867,20 @@
: suggestionKindToCompletionItemKind(
supportedCompletionItemKinds, suggestion.kind, label);
- var insertTextFormat = lsp.InsertTextFormat.PlainText;
-
- // 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 =
- (suggestion.defaultArgumentListTextRanges?.length ?? 0) > 0;
- final functionCallSuffix = hasRequiredParameters
- ? buildSnippetStringWithTabStops(
- suggestion.defaultArgumentListString,
- suggestion.defaultArgumentListTextRanges,
- )
- : '\${0:}'; // No required params still gets a tabstop in the parens.
- insertText += '($functionCallSuffix)';
- } else if (suggestion.selectionOffset != 0 &&
- // We don't need a tabstop if the selection is the end of the string.
- suggestion.selectionOffset != suggestion.completion.length) {
- insertTextFormat = lsp.InsertTextFormat.Snippet;
- insertText = buildSnippetStringWithTabStops(
- suggestion.completion,
- [suggestion.selectionOffset, suggestion.selectionLength],
- );
- }
- }
+ 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;
// Because we potentially send thousands of these items, we should minimise
// the generated JSON as much as possible - for example using nulls in place
@@ -899,8 +908,8 @@
// 10 -> 999990
// 1 -> 999999
sortText: (1000000 - suggestion.relevance).toString(),
- filterText: filterText != label
- ? filterText
+ filterText: suggestion.completion != label
+ ? suggestion.completion
: null, // filterText uses label if not set
insertText: insertText != label
? insertText
@@ -1269,3 +1278,51 @@
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;
+
+ // 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 += '($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);
+}
diff --git a/pkg/analysis_server/test/lsp/completion_dart_test.dart b/pkg/analysis_server/test/lsp/completion_dart_test.dart
index 304bbbb..f71db6c 100644
--- a/pkg/analysis_server/test/lsp/completion_dart_test.dart
+++ b/pkg/analysis_server/test/lsp/completion_dart_test.dart
@@ -177,6 +177,43 @@
expect(item.textEdit.newText, equals(item.insertText));
}
+ Future<void> test_completeFunctionCalls_suggestionSets() async {
+ final content = '''
+ main() {
+ [[pri]]^
+ }
+ ''';
+
+ final initialAnalysis = waitForAnalysisComplete();
+ await provideConfig(
+ () => initialize(
+ textDocumentCapabilities: withCompletionItemSnippetSupport(
+ emptyTextDocumentClientCapabilities),
+ workspaceCapabilities: withConfigurationSupport(
+ withApplyEditSupport(emptyWorkspaceClientCapabilities)),
+ ),
+ {'completeFunctionCalls': true},
+ );
+ await openFile(mainFileUri, withoutMarkers(content));
+ await initialAnalysis;
+ final res = await getCompletion(mainFileUri, positionFromMarker(content));
+ final item = res.singleWhere((c) => c.label == 'print(…)');
+ // Ensure the snippet comes through in the expected format with the expected
+ // placeholders.
+ expect(item.insertTextFormat, equals(InsertTextFormat.Snippet));
+ expect(item.insertText, equals(r'print(${0:object})'));
+ expect(item.textEdit, isNull);
+
+ // Ensure the item can be resolved and gets a proper TextEdit.
+ final resolved = await resolveCompletion(item);
+ expect(resolved.textEdit, isNotNull);
+ expect(resolved.textEdit.newText, equals(item.insertText));
+ expect(
+ resolved.textEdit.range,
+ equals(rangeFromMarkers(content)),
+ );
+ }
+
Future<void> test_completionKinds_default() async {
newFile(join(projectFolderPath, 'file.dart'));
newFolder(join(projectFolderPath, 'folder'));