Version 2.18.0-89.0.dev

Merge commit '7d6216940f5d300bf782cdaab6156f3b4b8af1c4' into 'dev'
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 5c1996d..c4b32d4 100644
--- a/pkg/analysis_server/lib/src/lsp/handlers/handler_completion.dart
+++ b/pkg/analysis_server/lib/src/lsp/handlers/handler_completion.dart
@@ -32,6 +32,7 @@
 import 'package:analyzer_plugin/protocol/protocol_common.dart';
 import 'package:analyzer_plugin/protocol/protocol_generated.dart' as plugin;
 import 'package:analyzer_plugin/src/utilities/completion/completion_target.dart';
+import 'package:collection/collection.dart';
 
 class CompletionHandler extends MessageHandler<CompletionParams, CompletionList>
     with LspPluginRequestHandlerMixin {
@@ -157,8 +158,7 @@
       }
     }
 
-    serverResultsFuture ??=
-        Future.value(success(_CompletionResults(isIncomplete: false)));
+    serverResultsFuture ??= Future.value(success(_CompletionResults.empty()));
 
     final pluginResultsFuture = _getPluginResults(
         clientCapabilities, lineInfo.result, path.result, offset);
@@ -174,12 +174,15 @@
         .toList();
     final unrankedItems = serverResults.result.unrankedItems;
 
-    // Truncate ranked items to allow room for all unranked items.
+    // Truncate ranked items allowing for all unranked items.
     final maxRankedItems = math.max(maxResults - unrankedItems.length, 0);
-    final truncatedRankedItems = untruncatedRankedItems.length > maxRankedItems
-        ? (untruncatedRankedItems..sort(sortTextComparer))
-            .sublist(0, maxRankedItems)
-        : untruncatedRankedItems;
+    final truncatedRankedItems = untruncatedRankedItems.length <= maxRankedItems
+        ? untruncatedRankedItems
+        : _truncateResults(
+            untruncatedRankedItems,
+            serverResults.result.targetPrefix,
+            maxRankedItems,
+          );
 
     final truncatedItems =
         truncatedRankedItems.followedBy(unrankedItems).toList();
@@ -308,11 +311,12 @@
       completionPreference: CompletionPreference.replace,
     );
     final target = completionRequest.target;
-    final fuzzy = _FuzzyFilterHelper(completionRequest.targetPrefix);
+    final targetPrefix = completionRequest.targetPrefix;
+    final fuzzy = _FuzzyFilterHelper(targetPrefix);
 
     if (triggerCharacter != null) {
       if (!_triggerCharacterValid(offset, triggerCharacter, target)) {
-        return success(_CompletionResults(isIncomplete: false));
+        return success(_CompletionResults.empty());
       }
     }
 
@@ -556,12 +560,13 @@
 
       return success(_CompletionResults(
           isIncomplete: false,
+          targetPrefix: targetPrefix,
           rankedItems: rankedResults,
           unrankedItems: unrankedResults));
     } on AbortCompletion {
-      return success(_CompletionResults(isIncomplete: false));
+      return success(_CompletionResults.empty());
     } on InconsistentAnalysisException {
-      return success(_CompletionResults(isIncomplete: false));
+      return success(_CompletionResults.empty());
     }
   }
 
@@ -614,8 +619,9 @@
           ),
         )
         .toList();
-    return success(_CompletionResults(
-        isIncomplete: false, unrankedItems: completionItems));
+    return success(
+      _CompletionResults.unranked(completionItems, isIncomplete: false),
+    );
   }
 
   /// Returns true if [node] is part of an invocation and already has an argument
@@ -708,6 +714,31 @@
     return true; // Any other trigger character can be handled always.
   }
 
+  /// Truncates [items] to [maxItems] but additionally includes any items that
+  /// exactly match [prefix].
+  Iterable<CompletionItem> _truncateResults(
+    List<CompletionItem> items,
+    String prefix,
+    int maxItems,
+  ) {
+    // Take the top `maxRankedItem` plus any exact matches.
+    final prefixLower = prefix.toLowerCase();
+    bool isExactMatch(CompletionItem item) =>
+        (item.filterText ?? item.label).toLowerCase() == prefixLower;
+
+    // Sort the items by relevance using sortText.
+    items.sort(sortTextComparer);
+
+    // Skip the text comparisons if we don't have a prefix (plugin results, or
+    // just no prefix when completion was invoked).
+    final shouldInclude = prefixLower.isEmpty
+        ? (int index, CompletionItem item) => index < maxItems
+        : (int index, CompletionItem item) =>
+            index < maxItems || isExactMatch(item);
+
+    return items.whereIndexed(shouldInclude);
+  }
+
   /// Compares [CompletionItem]s by the `sortText` field, which is derived from
   /// relevance.
   ///
@@ -750,13 +781,28 @@
   /// Items that cannot be ranked, and should avoid being truncated.
   final List<CompletionItem> unrankedItems;
 
+  /// Any prefixed used to filter the results.
+  final String targetPrefix;
+
   final bool isIncomplete;
 
   _CompletionResults({
     this.rankedItems = const [],
     this.unrankedItems = const [],
+    required this.targetPrefix,
     required this.isIncomplete,
   });
+
+  _CompletionResults.empty() : this(targetPrefix: '', isIncomplete: false);
+
+  _CompletionResults.unranked(
+    List<CompletionItem> unrankedItems, {
+    required bool isIncomplete,
+  }) : this(
+          unrankedItems: unrankedItems,
+          targetPrefix: '',
+          isIncomplete: isIncomplete,
+        );
 }
 
 /// Helper to simplify fuzzy filtering.
diff --git a/pkg/analysis_server/test/lsp/completion_dart_test.dart b/pkg/analysis_server/test/lsp/completion_dart_test.dart
index e7b5d87..8c28b61 100644
--- a/pkg/analysis_server/test/lsp/completion_dart_test.dart
+++ b/pkg/analysis_server/test/lsp/completion_dart_test.dart
@@ -1117,6 +1117,51 @@
     expect(res.items.map((item) => item.label).contains('aaa'), isTrue);
   }
 
+  /// Exact matches should always be included when completion lists are
+  /// truncated, even if they ranked poorly.
+  Future<void> test_maxCompletionItems_doesNotExcludeExactMatches() async {
+    final content = '''
+import 'a.dart';
+void f() {
+  var a = Item^
+}
+    ''';
+
+    // Create classes `Item1` to `Item20` along with a field named `item`.
+    // The classes will rank higher in the position above and push
+    // the field out without an exception to include exact matches.
+    newFile(
+      join(projectFolderPath, 'lib', 'a.dart'),
+      [
+        'String item = "";',
+        for (var i = 1; i <= 20; i++) 'class Item$i {}',
+      ].join('\n'),
+    );
+
+    final initialAnalysis = waitForAnalysisComplete();
+    await provideConfig(
+      () => initialize(
+          workspaceCapabilities: withApplyEditSupport(
+              withConfigurationSupport(emptyWorkspaceClientCapabilities))),
+      {'maxCompletionItems': 10},
+    );
+    await openFile(mainFileUri, withoutMarkers(content));
+    await initialAnalysis;
+    final res =
+        await getCompletionList(mainFileUri, positionFromMarker(content));
+
+    // We expect 11 items, because the exact match was not in the top 10 and
+    // was included additionally.
+    expect(res.items, hasLength(11));
+    expect(res.isIncomplete, isTrue);
+
+    // Ensure the 'Item' field is included.
+    expect(
+      res.items.map((item) => item.label),
+      contains('item'),
+    );
+  }
+
   /// Snippet completions should be kept when maxCompletionItems truncates
   /// because they are not ranked like other completions and might be
   /// truncated when they are exactly what the user wants.
@@ -1128,11 +1173,11 @@
 }
     ''';
 
-    // Create a class with fields for1 to for20 in the other file.
+    // Create fields for1 to for20 in the other file.
     newFile(
       join(projectFolderPath, 'lib', 'a.dart'),
       [
-        for (var i = 1; i <= 20; i++) '  String for$i = ' ';',
+        for (var i = 1; i <= 20; i++) 'String for$i = ' ';',
       ].join('\n'),
     );
 
@@ -1154,8 +1199,7 @@
     expect(res.items, hasLength(10));
     expect(res.isIncomplete, isTrue);
 
-    // Also ensure the 'for' snippet is included.
-
+    // Ensure the 'for' snippet is included.
     expect(
       res.items
           .where((item) => item.kind == CompletionItemKind.Snippet)
diff --git a/tools/VERSION b/tools/VERSION
index 9aaaf90..84dbb00 100644
--- a/tools/VERSION
+++ b/tools/VERSION
@@ -27,5 +27,5 @@
 MAJOR 2
 MINOR 18
 PATCH 0
-PRERELEASE 88
+PRERELEASE 89
 PRERELEASE_PATCH 0
\ No newline at end of file