[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'));