[analysis_server] Add support for additional completion label details
This allows more of the signature and URI to be shown in the full completion list without needing to cursor through each item to see it.
Some examples can be seen in https://github.com/Dart-Code/Dart-Code/issues/2462#issuecomment-1663786808
Fixes https://github.com/Dart-Code/Dart-Code/issues/2462
Change-Id: Ib8290f90c31f974271109df7912d230b5e824319
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/323380
Reviewed-by: Brian Wilkerson <brianwilkerson@google.com>
Commit-Queue: Brian Wilkerson <brianwilkerson@google.com>
diff --git a/pkg/analysis_server/lib/src/lsp/client_capabilities.dart b/pkg/analysis_server/lib/src/lsp/client_capabilities.dart
index 150d0b1..3d04b467 100644
--- a/pkg/analysis_server/lib/src/lsp/client_capabilities.dart
+++ b/pkg/analysis_server/lib/src/lsp/client_capabilities.dart
@@ -82,6 +82,7 @@
final Set<SymbolKind> workspaceSymbolKinds;
final Set<CompletionItemKind> completionItemKinds;
final Set<InsertTextMode> completionInsertTextModes;
+ final bool completionLabelDetails;
final bool completionDefaultEditRange;
final bool completionDefaultTextMode;
final bool experimentalSnippetTextEdit;
@@ -121,6 +122,7 @@
final completionItemKinds = _listToSet(
completion?.completionItemKind?.valueSet,
defaults: defaultSupportedCompletionKinds);
+ final completionLabelDetails = completionItem?.labelDetailsSupport ?? false;
final completionSnippets = completionItem?.snippetSupport ?? false;
final completionDefaultEditRange = completionDefaults.contains('editRange');
final completionDefaultTextMode =
@@ -200,6 +202,7 @@
workspaceSymbolKinds: workspaceSymbolKinds,
completionItemKinds: completionItemKinds,
completionInsertTextModes: completionInsertTextModes,
+ completionLabelDetails: completionLabelDetails,
completionDefaultEditRange: completionDefaultEditRange,
completionDefaultTextMode: completionDefaultTextMode,
experimentalSnippetTextEdit: experimentalSnippetTextEdit,
@@ -236,6 +239,7 @@
required this.workspaceSymbolKinds,
required this.completionItemKinds,
required this.completionInsertTextModes,
+ required this.completionLabelDetails,
required this.completionDefaultEditRange,
required this.completionDefaultTextMode,
required this.experimentalSnippetTextEdit,
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 1b01937..821f91a 100644
--- a/pkg/analysis_server/lib/src/lsp/handlers/handler_completion.dart
+++ b/pkg/analysis_server/lib/src/lsp/handlers/handler_completion.dart
@@ -810,6 +810,8 @@
allCommitCharacters:
previewCommitCharacters ? dartCompletionCommitCharacters : null,
resolveProvider: true,
+ completionItem:
+ CompletionOptionsCompletionItem(labelDetailsSupport: true),
),
),
(
@@ -847,6 +849,8 @@
allCommitCharacters:
previewCommitCharacters ? dartCompletionCommitCharacters : null,
resolveProvider: true,
+ completionItem:
+ CompletionOptionsCompletionItem(labelDetailsSupport: true),
);
@override
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 76d4645..d0c8b6a 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
@@ -167,6 +167,7 @@
kind: item.kind,
tags: item.tags,
detail: detail,
+ labelDetails: item.labelDetails,
documentation: documentation,
deprecated: item.deprecated,
preselect: item.preselect,
diff --git a/pkg/analysis_server/lib/src/lsp/mapping.dart b/pkg/analysis_server/lib/src/lsp/mapping.dart
index 7458833..aab17e6 100644
--- a/pkg/analysis_server/lib/src/lsp/mapping.dart
+++ b/pkg/analysis_server/lib/src/lsp/mapping.dart
@@ -410,6 +410,14 @@
(final parameters?, '') => parameters,
(final parameters?, _) => '$parameters → $returnType',
};
+ final truncatedSignature = switch ((parameters, returnType)) {
+ (null, null) => '',
+ // Include a leading space when no parameters so return type isn't right
+ // against the completion label.
+ (null, final returnType?) => ' $returnType',
+ (_, null) || (_, '') => truncatedParameters,
+ (_, final returnType?) => '$truncatedParameters → $returnType',
+ };
// Use the full signature in the details popup.
var detail = fullSignature;
@@ -419,9 +427,14 @@
detail = '$detail\n\n(Deprecated)'.trim();
}
+ final autoImportUri =
+ (suggestion.isNotImported ?? false) ? suggestion.libraryUri : null;
+
return (
detail: detail,
truncatedParams: truncatedParameters,
+ truncatedSignature: truncatedSignature,
+ autoImportUri: autoImportUri,
);
}
@@ -860,19 +873,6 @@
required bool completeFunctionCalls,
CompletionItemResolutionInfo? resolutionData,
}) {
- var label = suggestion.displayText ?? suggestion.completion;
- assert(label.isNotEmpty);
-
- // Displayed labels may have additional info appended (for example '(...)' on
- // callables and ` => ` on getters) that should not be included in filterText,
- // so strip anything from the first paren/space.
- final filterText = label.split(_completionFilterTextSplitPattern).first;
-
- // 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.
//
@@ -901,6 +901,27 @@
final supportsInsertReplace = capabilities.insertReplaceCompletionRanges;
final supportsAsIsInsertMode =
capabilities.completionInsertTextModes.contains(InsertTextMode.asIs);
+ final useLabelDetails = capabilities.completionLabelDetails;
+
+ var label = suggestion.displayText ?? suggestion.completion;
+ assert(label.isNotEmpty);
+
+ // Displayed labels may have additional info appended (for example '(...)' on
+ // callables and ` => ` on getters) that should not be included in filterText,
+ // so strip anything from the first paren/space.
+ final filterText = label.split(_completionFilterTextSplitPattern).first;
+
+ // If we're using label details, we also don't want the label to include any
+ // additional symbols as noted above, because they will appear in the extra
+ // details fields.
+ if (useLabelDetails) {
+ label = filterText;
+ }
+
+ // Trim any trailing comma from the (displayed) label.
+ if (label.endsWith(',')) {
+ label = label.substring(0, label.length - 1);
+ }
final element = suggestion.element;
final completionKind = element != null
@@ -915,9 +936,10 @@
supportsCompletionDeprecatedFlag || supportsDeprecatedTag,
);
- // Include short params on the end of labels as long as the item doesn't have
- // custom display text (which may already include params).
- if (suggestion.displayText == null) {
+ // For legacy display, include short params on the end of labels as long as
+ // the item doesn't have custom display text (which may already include
+ // params).
+ if (!useLabelDetails && suggestion.displayText == null) {
label += labelDetails.truncatedParams;
}
@@ -954,6 +976,8 @@
labelDetails = (
detail: labelMatch.group(1)!,
truncatedParams: labelDetails.truncatedParams,
+ truncatedSignature: labelDetails.truncatedSignature,
+ autoImportUri: labelDetails.autoImportUri,
);
}
@@ -969,6 +993,12 @@
]),
data: resolutionData,
detail: labelDetails.detail.nullIfEmpty,
+ labelDetails: useLabelDetails
+ ? CompletionItemLabelDetails(
+ detail: labelDetails.truncatedSignature.nullIfEmpty,
+ description: labelDetails.autoImportUri,
+ )
+ : null,
documentation: cleanedDoc != null
? asMarkupContentOrString(formats, cleanedDoc)
: null,
@@ -1467,4 +1497,13 @@
/// differently and is appended immediately after the completion label. The
/// return type is ommitted to reduce noise because this text is not subtle.
String truncatedParams,
+
+ /// A signature with truncated params. Used for showing immediately after
+ /// the completion label when it can be formatted differently.
+ ///
+ /// () → String
+ String truncatedSignature,
+
+ /// The URI that will be auto-imported if this item is selected.
+ String? autoImportUri,
});
diff --git a/pkg/analysis_server/test/lsp/completion_dart_test.dart b/pkg/analysis_server/test/lsp/completion_dart_test.dart
index 098b6df..114338d 100644
--- a/pkg/analysis_server/test/lsp/completion_dart_test.dart
+++ b/pkg/analysis_server/test/lsp/completion_dart_test.dart
@@ -25,6 +25,7 @@
import 'package:analysis_server/src/services/snippets/dart/test_group_definition.dart';
import 'package:analysis_server/src/services/snippets/dart/try_catch_statement.dart';
import 'package:analysis_server/src/services/snippets/dart/while_statement.dart';
+import 'package:analyzer/src/test_utilities/test_code_format.dart';
import 'package:analyzer_plugin/protocol/protocol_common.dart' as plugin;
import 'package:analyzer_plugin/protocol/protocol_generated.dart' as plugin;
import 'package:collection/collection.dart';
@@ -33,12 +34,14 @@
import 'package:test_reflective_loader/test_reflective_loader.dart';
import '../tool/lsp_spec/matchers.dart';
+import '../utils/test_code_extensions.dart';
import 'completion.dart';
import 'server_abstract.dart';
void main() {
defineReflectiveSuite(() {
defineReflectiveTests(CompletionTest);
+ defineReflectiveTests(CompletionLabelDetailsTest);
defineReflectiveTests(CompletionDocumentationResolutionTest);
defineReflectiveTests(DartSnippetCompletionTest);
defineReflectiveTests(FlutterSnippetCompletionTest);
@@ -227,6 +230,351 @@
}
@reflectiveTest
+class CompletionLabelDetailsTest extends AbstractCompletionTest {
+ late String fileAPath;
+
+ Future<void> expectLabels(
+ String content, {
+ // Main label of the completion (eg 'myFunc')
+ required String? label,
+ // The detail part of the label (shown after label, usually truncated signature)
+ required String? labelDetail,
+ // Additional label description (usually the auto-import URI)
+ required String? labelDescription,
+ // Filter text (usually same as label, never with `()` or other suffixes)
+ required String? filterText,
+ // Main detail (shown in popout, usually full signature)
+ required String? detail,
+ // Sometimes resolved detail has a prefix added (eg. "Auto-import from").
+ String? resolvedDetailPrefix,
+ }) async {
+ final code = TestCode.parse(content);
+ await initialize();
+ await openFile(mainFileUri, code.code);
+
+ final completions =
+ await getCompletion(mainFileUri, code.position.position);
+ final completion = completions.singleWhereOrNull((c) => c.label == label);
+ if (completion == null) {
+ fail('Did not find completion "$label" in completion results:'
+ '\n ${completions.map((c) => c.label).join('\n ')}');
+ }
+
+ final labelDetails = completion.labelDetails;
+ if (labelDetails == null) {
+ fail('Completion "$label" does not have labelDetails');
+ }
+
+ expect(completion.detail, detail);
+ expect(completion.filterText, filterText);
+ expect(labelDetails.detail, labelDetail);
+ expect(labelDetails.description, labelDescription);
+
+ // Verify that resolution does not modify these results.
+ final resolved = await resolveCompletion(completion);
+ expect(resolved.label, completion.label);
+ expect(resolved.filterText, completion.filterText);
+ expect(
+ resolved.detail,
+ '${resolvedDetailPrefix ?? ''}${completion.detail}',
+ );
+ expect(resolved.labelDetails?.detail, completion.labelDetails?.detail);
+ expect(
+ resolved.labelDetails?.description,
+ completion.labelDetails?.description,
+ );
+ }
+
+ @override
+ void setUp() {
+ super.setUp();
+ fileAPath = join(projectFolderPath, 'lib', 'a.dart');
+
+ // TODO(dantup): Consider enabling this by default for [CompletionTest] and
+ // changing this class to test support without it (or, subclassing
+ // CompletionTest and inferring the label when labelDetails are not
+ // supported).
+ setCompletionItemLabelDetailsSupport();
+ }
+
+ Future<void> test_imported_function_returnType_args() async {
+ newFile(fileAPath, '''
+String a(String a, {String b}) {}
+''');
+ final content = '''
+import 'a.dart';
+void f() {
+ a^
+}
+''';
+
+ await expectLabels(content,
+ label: 'a',
+ labelDetail: '(…) → String',
+ labelDescription: null,
+ filterText: null,
+ detail: '(String a, {String b}) → String');
+ }
+
+ Future<void> test_imported_function_returnType_noArgs() async {
+ newFile(fileAPath, '''
+String a() {}
+''');
+ final content = '''
+import 'a.dart';
+String f() {
+ a^
+}
+''';
+
+ await expectLabels(content,
+ label: 'a',
+ labelDetail: '() → String',
+ labelDescription: null,
+ filterText: null,
+ detail: '() → String');
+ }
+
+ Future<void> test_imported_function_void_args() async {
+ newFile(fileAPath, '''
+void a(String a, {String b}) {}
+''');
+ final content = '''
+import 'a.dart';
+void f() {
+ a^
+}
+''';
+
+ await expectLabels(content,
+ label: 'a',
+ labelDetail: '(…) → void',
+ labelDescription: null,
+ filterText: null,
+ detail: '(String a, {String b}) → void');
+ }
+
+ Future<void> test_imported_function_void_noArgs() async {
+ newFile(fileAPath, '''
+void a() {}
+''');
+ final content = '''
+import 'a.dart';
+void f() {
+ a^
+}
+''';
+
+ await expectLabels(content,
+ label: 'a',
+ labelDetail: '() → void',
+ labelDescription: null,
+ filterText: null,
+ detail: '() → void');
+ }
+
+ Future<void> test_local_function_returnType_args() async {
+ final content = '''
+String f(String a, {String b}) {
+ f^
+}
+''';
+ await expectLabels(content,
+ label: 'f',
+ labelDetail: '(…) → String',
+ labelDescription: null,
+ filterText: null,
+ detail: '(String a, {String b}) → String');
+ }
+
+ Future<void> test_local_function_returnType_noArgs() async {
+ final content = '''
+String f() {
+ f^
+}
+''';
+
+ await expectLabels(content,
+ label: 'f',
+ labelDetail: '() → String',
+ labelDescription: null,
+ filterText: null,
+ detail: '() → String');
+ }
+
+ Future<void> test_local_function_void_args() async {
+ final content = '''
+void f(String a, {String b}) {
+ f^
+}
+''';
+
+ await expectLabels(content,
+ label: 'f',
+ labelDetail: '(…) → void',
+ labelDescription: null,
+ filterText: null,
+ detail: '(String a, {String b}) → void');
+ }
+
+ Future<void> test_local_function_void_noArgs() async {
+ final content = '''
+void f() {
+ f^
+}
+''';
+
+ await expectLabels(content,
+ label: 'f',
+ labelDetail: '() → void',
+ labelDescription: null,
+ filterText: null,
+ detail: '() → void');
+ }
+
+ Future<void> test_local_getter() async {
+ final content = '''
+String a => '';
+void f() {
+ a^
+}
+''';
+ await expectLabels(content,
+ label: 'a',
+ labelDetail: '() → String',
+ labelDescription: null,
+ filterText: null,
+ detail: '() → String');
+ }
+
+ Future<void> test_local_getterAndSetter() async {
+ final content = '''
+String a => '';
+set a(String value) {}
+void f() {
+ a^
+}
+''';
+ await expectLabels(content,
+ label: 'a',
+ labelDetail: '() → String',
+ labelDescription: null,
+ filterText: null,
+ detail: '() → String');
+ }
+
+ Future<void> test_local_override() async {
+ // TODO(dantup): Debug why using "a" instead of "aa" doesn't work.
+ final content = '''
+class Base {
+ String aa(String a) => '';
+}
+
+class Derived extends Base {
+ a^
+}
+''';
+ await expectLabels(content,
+ label: 'aa',
+ labelDetail: '(…) → String',
+ labelDescription: null,
+ filterText: null,
+ detail: '(String a) → String');
+ }
+
+ Future<void> test_local_setter() async {
+ final content = '''
+set a(String value) {}
+void f() {
+ a^
+}
+''';
+ await expectLabels(content,
+ label: 'a',
+ labelDetail: ' String',
+ labelDescription: null,
+ filterText: null,
+ detail: 'String');
+ }
+
+ Future<void> test_notImported_function_returnType_args() async {
+ newFile(fileAPath, '''
+String a(String a, {String b}) {}
+''');
+ final content = '''
+void f() {
+ a^
+}
+''';
+
+ await expectLabels(content,
+ label: 'a',
+ labelDetail: '(…) → String',
+ labelDescription: 'package:test/a.dart',
+ filterText: null,
+ detail: '(String a, {String b}) → String',
+ resolvedDetailPrefix: "Auto import from 'package:test/a.dart'\n\n");
+ }
+
+ Future<void> test_notImported_function_returnType_noArgs() async {
+ newFile(fileAPath, '''
+String a() {}
+''');
+ final content = '''
+String f() {
+ a^
+}
+''';
+
+ await expectLabels(content,
+ label: 'a',
+ labelDetail: '() → String',
+ labelDescription: 'package:test/a.dart',
+ filterText: null,
+ detail: '() → String',
+ resolvedDetailPrefix: "Auto import from 'package:test/a.dart'\n\n");
+ }
+
+ Future<void> test_notImported_function_void_args() async {
+ newFile(fileAPath, '''
+void a(String a, {String b}) {}
+''');
+ final content = '''
+void f() {
+ a^
+}
+''';
+
+ await expectLabels(content,
+ label: 'a',
+ labelDetail: '(…) → void',
+ labelDescription: 'package:test/a.dart',
+ filterText: null,
+ detail: '(String a, {String b}) → void',
+ resolvedDetailPrefix: "Auto import from 'package:test/a.dart'\n\n");
+ }
+
+ Future<void> test_notImported_function_void_noArgs() async {
+ newFile(fileAPath, '''
+void a() {}
+''');
+ final content = '''
+void f() {
+ a^
+}
+''';
+
+ await expectLabels(content,
+ label: 'a',
+ labelDetail: '() → void',
+ labelDescription: 'package:test/a.dart',
+ filterText: null,
+ detail: '() → void',
+ resolvedDetailPrefix: "Auto import from 'package:test/a.dart'\n\n");
+ }
+}
+
+@reflectiveTest
class CompletionTest extends AbstractCompletionTest {
/// Checks whether the correct types of documentation are returned for
/// completions based on [preference].
diff --git a/pkg/analysis_server/test/lsp/server_abstract.dart b/pkg/analysis_server/test/lsp/server_abstract.dart
index 836dd2f..6a1c89c 100644
--- a/pkg/analysis_server/test/lsp/server_abstract.dart
+++ b/pkg/analysis_server/test/lsp/server_abstract.dart
@@ -435,6 +435,11 @@
withApplyEditSupport(workspaceCapabilities, supported);
}
+ void setCompletionItemLabelDetailsSupport([bool supported = true]) {
+ textDocumentCapabilities = withCompletionItemLabelDetailsSupport(
+ textDocumentCapabilities, supported);
+ }
+
void setCompletionItemSnippetSupport([bool supported = true]) {
textDocumentCapabilities =
withCompletionItemSnippetSupport(textDocumentCapabilities, supported);
@@ -615,6 +620,17 @@
});
}
+ TextDocumentClientCapabilities withCompletionItemLabelDetailsSupport(
+ TextDocumentClientCapabilities source, [
+ bool supported = true,
+ ]) {
+ return extendTextDocumentCapabilities(source, {
+ 'completion': {
+ 'completionItem': {'labelDetailsSupport': supported}
+ }
+ });
+ }
+
TextDocumentClientCapabilities withCompletionItemSnippetSupport(
TextDocumentClientCapabilities source, [
bool supported = true,