[Analyzer] Add an LSP setting for including parameters in completions

Fixes https://github.com/Dart-Code/Dart-Code/issues/2823.

Change-Id: I477aba5f3563c01a85bb867a1e01f294eed3636e
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/171585
Commit-Queue: Danny Tuppeny <danny@tuppeny.com>
Reviewed-by: Brian Wilkerson <brianwilkerson@google.com>
diff --git a/pkg/analysis_server/lib/src/lsp/client_configuration.dart b/pkg/analysis_server/lib/src/lsp/client_configuration.dart
index 55bfaf5..8af5a85 100644
--- a/pkg/analysis_server/lib/src/lsp/client_configuration.dart
+++ b/pkg/analysis_server/lib/src/lsp/client_configuration.dart
@@ -20,6 +20,7 @@
     }
   }
 
+  bool get completeFunctionCalls => _settings['completeFunctionCalls'] ?? false;
   bool get enableSdkFormatter => _settings['enableSdkFormatter'] ?? true;
   int get lineLength => _settings['lineLength'];
 
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 430489a..e9d0fcd 100644
--- a/pkg/analysis_server/lib/src/lsp/handlers/handler_completion.dart
+++ b/pkg/analysis_server/lib/src/lsp/handlers/handler_completion.dart
@@ -239,6 +239,8 @@
                 // https://github.com/microsoft/vscode-languageserver-node/issues/673
                 includeCommitCharacters:
                     server.clientConfiguration.previewCommitCharacters,
+                completeFunctionCalls:
+                    server.clientConfiguration.completeFunctionCalls,
               ),
             )
             .toList();
@@ -372,6 +374,7 @@
           // not assume that the Dart ones would be correct for all of their
           // completions.
           includeCommitCharacters: false,
+          completeFunctionCalls: false,
         ),
       );
     });
diff --git a/pkg/analysis_server/lib/src/lsp/mapping.dart b/pkg/analysis_server/lib/src/lsp/mapping.dart
index 039b503..e425b2d 100644
--- a/pkg/analysis_server/lib/src/lsp/mapping.dart
+++ b/pkg/analysis_server/lib/src/lsp/mapping.dart
@@ -62,13 +62,14 @@
           _asMarkup(preferredFormats, content));
 }
 
-/// Builds an LSP snippet string that uses a $1 tabstop to set the selected text
-/// after insertion.
-String buildSnippetStringWithSelection(
+/// Builds an LSP snippet string with supplied ranges as tabstops.
+String buildSnippetStringWithTabStops(
   String text,
-  int selectionOffset,
-  int selectionLength,
+  List<int> offsetLengthPairs,
 ) {
+  text ??= '';
+  offsetLengthPairs ??= const [];
+
   String escape(String input) => input.replaceAllMapped(
         RegExp(r'[$}\\]'), // Replace any of $ } \
         (c) => '\\${c[0]}', // Prefix with a backslash
@@ -77,17 +78,29 @@
   // https://microsoft.github.io/language-server-protocol/specifications/specification-3-14/#snippet-syntax
   //
   // $1, $2, etc. are used for tab stops and ${1:foo} inserts a placeholder of foo.
-  // Since we only need to support a single tab stop, our string is constructed of three parts:
-  // - Anything before the selection
-  // - The selection (which may or may not include text, depending on selectionLength)
-  // - Anything after the selection
-  final prefix = escape(text.substring(0, selectionOffset));
-  final selectionText = escape(
-      text.substring(selectionOffset, selectionOffset + selectionLength));
-  final selection = '\${1:$selectionText}';
-  final suffix = escape(text.substring(selectionOffset + selectionLength));
 
-  return '$prefix$selection$suffix';
+  final output = [];
+  var offset = 0;
+  var tabStopNumber = 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(escape(text.substring(offset, pairOffset)));
+
+    // Add this tabstop
+    final tabStopText =
+        escape(text.substring(pairOffset, pairOffset + pairLength));
+    output.add('\${${tabStopNumber++}:$tabStopText}');
+
+    offset = pairOffset + pairLength;
+  }
+
+  // Add any remaining text that was after the last tabstop.
+  output.add(escape(text.substring(offset)));
+
+  return output.join('');
 }
 
 /// Note: This code will fetch the version of each document being modified so
@@ -769,6 +782,7 @@
   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
@@ -782,14 +796,24 @@
     label = label.substring(0, label.length - 1);
   }
 
-  if (suggestion.displayText == null) {
-    switch (suggestion.element?.kind) {
-      case server.ElementKind.CONSTRUCTOR:
-      case server.ElementKind.FUNCTION:
-      case server.ElementKind.METHOD:
-        label += suggestion.parameterNames?.isNotEmpty ?? false ? '(…)' : '()';
-        break;
-    }
+  // 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 supportsDeprecatedFlag =
@@ -809,13 +833,33 @@
           supportedCompletionItemKinds, suggestion.kind, label);
 
   var insertTextFormat = lsp.InsertTextFormat.PlainText;
-  if (supportsSnippets && suggestion.selectionOffset != 0) {
-    insertTextFormat = lsp.InsertTextFormat.Snippet;
-    insertText = buildSnippetStringWithSelection(
-      suggestion.completion,
-      suggestion.selectionOffset,
-      suggestion.selectionLength,
-    );
+
+  // 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,
+            )
+          : '\${1:}'; // No required params still gets a tabstop in the parens.
+      insertText += '($functionCallSuffix)';
+    } else if (suggestion.selectionOffset != 0) {
+      insertTextFormat = lsp.InsertTextFormat.Snippet;
+      insertText = buildSnippetStringWithTabStops(
+        suggestion.completion,
+        [suggestion.selectionOffset, suggestion.selectionLength],
+      );
+    }
   }
 
   // Because we potentially send thousands of these items, we should minimise
diff --git a/pkg/analysis_server/test/lsp/completion_test.dart b/pkg/analysis_server/test/lsp/completion_test.dart
index cdecc6f..94ff72b 100644
--- a/pkg/analysis_server/test/lsp/completion_test.dart
+++ b/pkg/analysis_server/test/lsp/completion_test.dart
@@ -15,6 +15,7 @@
 void main() {
   defineReflectiveSuite(() {
     defineReflectiveTests(CompletionTest);
+    defineReflectiveTests(CompletionTestWithNullSafetyTest);
   });
 }
 
@@ -89,6 +90,93 @@
     expect(options.allCommitCharacters, equals(dartCompletionCommitCharacters));
   }
 
+  Future<void> test_completeFunctionCalls() async {
+    final content = '''
+    void myFunction(String a, int b, {String c}) {}
+
+    main() {
+      [[myFu^]]
+    }
+    ''';
+
+    await provideConfig(
+      () => initialize(
+        textDocumentCapabilities: withCompletionItemSnippetSupport(
+            emptyTextDocumentClientCapabilities),
+        workspaceCapabilities:
+            withConfigurationSupport(emptyWorkspaceClientCapabilities),
+      ),
+      {'completeFunctionCalls': true},
+    );
+    await openFile(mainFileUri, withoutMarkers(content));
+    final res = await getCompletion(mainFileUri, positionFromMarker(content));
+    final item = res.singleWhere((c) => c.label == 'myFunction(…)');
+    // Ensure the snippet comes through in the expected format with the expected
+    // placeholders.
+    expect(item.insertTextFormat, equals(InsertTextFormat.Snippet));
+    expect(item.insertText, equals(r'myFunction(${1:a}, ${2:b})'));
+    expect(item.textEdit.newText, equals(item.insertText));
+    expect(
+      item.textEdit.range,
+      equals(rangeFromMarkers(content)),
+    );
+  }
+
+  Future<void> test_completeFunctionCalls_noRequiredParameters() async {
+    final content = '''
+    void myFunction({int a}) {}
+
+    main() {
+      [[myFu^]]
+    }
+    ''';
+
+    await provideConfig(
+      () => initialize(
+        textDocumentCapabilities: withCompletionItemSnippetSupport(
+            emptyTextDocumentClientCapabilities),
+        workspaceCapabilities:
+            withConfigurationSupport(emptyWorkspaceClientCapabilities),
+      ),
+      {'completeFunctionCalls': true},
+    );
+    await openFile(mainFileUri, withoutMarkers(content));
+    final res = await getCompletion(mainFileUri, positionFromMarker(content));
+    final item = res.singleWhere((c) => c.label == 'myFunction(…)');
+    // With no required params, there should still be parens and a tabstop inside.
+    expect(item.insertTextFormat, equals(InsertTextFormat.Snippet));
+    expect(item.insertText, equals(r'myFunction(${1:})'));
+    expect(item.textEdit.newText, equals(item.insertText));
+    expect(
+      item.textEdit.range,
+      equals(rangeFromMarkers(content)),
+    );
+  }
+
+  Future<void> test_completeFunctionCalls_show() async {
+    final content = '''
+    import 'dart:math' show mi^
+    ''';
+
+    await provideConfig(
+      () => initialize(
+        textDocumentCapabilities: withCompletionItemSnippetSupport(
+            emptyTextDocumentClientCapabilities),
+        workspaceCapabilities:
+            withConfigurationSupport(emptyWorkspaceClientCapabilities),
+      ),
+      {'completeFunctionCalls': true},
+    );
+    await openFile(mainFileUri, withoutMarkers(content));
+    final res = await getCompletion(mainFileUri, positionFromMarker(content));
+    final item = res.singleWhere((c) => c.label == 'min(…)');
+    // Ensure the snippet does not include the parens/args as it doesn't
+    // make sense in the show clause. There will still be a trailing tabstop.
+    expect(item.insertTextFormat, equals(InsertTextFormat.Snippet));
+    expect(item.insertText, equals(r'min${1:}'));
+    expect(item.textEdit.newText, equals(item.insertText));
+  }
+
   Future<void> test_completionKinds_default() async {
     newFile(join(projectFolderPath, 'file.dart'));
     newFolder(join(projectFolderPath, 'folder'));
@@ -1237,3 +1325,44 @@
     expect(updated, contains('a.abcdefghij'));
   }
 }
+
+@reflectiveTest
+class CompletionTestWithNullSafetyTest extends AbstractLspAnalysisServerTest {
+  @override
+  String get testPackageLanguageVersion => latestLanguageVersion;
+
+  @failingTest
+  Future<void> test_completeFunctionCalls_requiredNamed() async {
+    // TODO(dantup): Find out how we can tell this parameter is required
+    // (in the completion mapping).
+    final content = '''
+    void myFunction(String a, int b, {required String c, String d = ''}) {}
+
+    main() {
+      [[myFu^]]
+    }
+    ''';
+
+    await provideConfig(
+      () => initialize(
+        textDocumentCapabilities: withCompletionItemSnippetSupport(
+            emptyTextDocumentClientCapabilities),
+        workspaceCapabilities:
+            withConfigurationSupport(emptyWorkspaceClientCapabilities),
+      ),
+      {'completeFunctionCalls': true},
+    );
+    await openFile(mainFileUri, withoutMarkers(content));
+    final res = await getCompletion(mainFileUri, positionFromMarker(content));
+    final item = res.singleWhere((c) => c.label == 'myFunction(…)');
+    // Ensure the snippet comes through in the expected format with the expected
+    // placeholders.
+    expect(item.insertTextFormat, equals(InsertTextFormat.Snippet));
+    expect(item.insertText, equals(r'myFunction(${1:a}, ${2:b}, ${2:c})'));
+    expect(item.textEdit.newText, equals(item.insertText));
+    expect(
+      item.textEdit.range,
+      equals(rangeFromMarkers(content)),
+    );
+  }
+}
diff --git a/pkg/analysis_server/test/lsp/mapping_test.dart b/pkg/analysis_server/test/lsp/mapping_test.dart
index 2ae7ac1..72bf3f6 100644
--- a/pkg/analysis_server/test/lsp/mapping_test.dart
+++ b/pkg/analysis_server/test/lsp/mapping_test.dart
@@ -74,18 +74,35 @@
     expect(result, isNull);
   }
 
-  Future<void> test_selectionsInSnippets_empty() async {
-    var result = lsp.buildSnippetStringWithSelection('teststring', 4, 0);
-    expect(result, equals(r'test${1:}string'));
+  Future<void> test_tabStopsInSnippets_contains() async {
+    var result = lsp.buildSnippetStringWithTabStops('a, b, c', [3, 1]);
+    expect(result, equals(r'a, ${1:b}, c'));
   }
 
-  Future<void> test_selectionsInSnippets_escaping() async {
-    var result = lsp.buildSnippetStringWithSelection(r'te$tstri}ng', 4, 3);
-    expect(result, equals(r'te\$t${1:str}i\}ng'));
+  Future<void> test_tabStopsInSnippets_empty() async {
+    var result = lsp.buildSnippetStringWithTabStops('a, b', []);
+    expect(result, equals(r'a, b'));
   }
 
-  Future<void> test_selectionsInSnippets_selection() async {
-    var result = lsp.buildSnippetStringWithSelection('teststring', 4, 3);
-    expect(result, equals(r'test${1:str}ing'));
+  Future<void> test_tabStopsInSnippets_endsWith() async {
+    var result = lsp.buildSnippetStringWithTabStops('a, b', [3, 1]);
+    expect(result, equals(r'a, ${1:b}'));
+  }
+
+  Future<void> test_tabStopsInSnippets_escape() async {
+    var result = lsp.buildSnippetStringWithTabStops(
+        r'te$tstri}ng, te$tstri}ng, te$tstri}ng', [13, 11]);
+    expect(result, equals(r'te\$tstri\}ng, ${1:te\$tstri\}ng}, te\$tstri\}ng'));
+  }
+
+  Future<void> test_tabStopsInSnippets_multiple() async {
+    var result =
+        lsp.buildSnippetStringWithTabStops('a, b, c', [0, 1, 3, 1, 6, 1]);
+    expect(result, equals(r'${1:a}, ${2:b}, ${3:c}'));
+  }
+
+  Future<void> test_tabStopsInSnippets_startsWith() async {
+    var result = lsp.buildSnippetStringWithTabStops('a, b', [0, 1]);
+    expect(result, equals(r'${1:a}, b'));
   }
 }
diff --git a/pkg/analysis_server/tool/lsp_spec/README.md b/pkg/analysis_server/tool/lsp_spec/README.md
index 051abb3..e95c70a 100644
--- a/pkg/analysis_server/tool/lsp_spec/README.md
+++ b/pkg/analysis_server/tool/lsp_spec/README.md
@@ -31,6 +31,7 @@
 
 - `dart.enableSdkFormatter`: When set to `false`, prevents registration (or unregisters) the SDK formatter. When set to `true` or not supplied, will register/reregister the SDK formatter.
 - `dart.lineLength`: The number of characters the formatter should wrap code at. If unspecified, code will be wrapped at `80` characters.
+- `dart.completeFunctionCalls`: Completes functions/methods with their required parameters.
 
 ## Method Status