diff --git a/docs/LSPApi.md b/docs/LSPApi.md index f6b4e7f07..ef70e11ac 100644 --- a/docs/LSPApi.md +++ b/docs/LSPApi.md @@ -233,6 +233,7 @@ public class MyLSPCodeLensFeature extends LSPCodeLensFeature { | boolean isStrikeout(CompletionItem item) | Returns true if the IntelliJ lookup is strike out and false otherwise. | use `item.getDeprecated()` or `item.getTags().contains(CompletionItemTag.Deprecated)` | | String getTailText(CompletionItem item) | Returns the IntelliJ lookup tail text from the given LSP completion item and null otherwise. | `item.getLabelDetails().getDetail()` | | boolean isItemTextBold(CompletionItem item) | Returns the IntelliJ lookup item text bold from the given LSP completion item and null otherwise. | `item.getKind() == CompletionItemKind.Keyword` | +| boolean useContextAwareSorting(PsiFile file) | Returns `true` if client-side context-aware completion sorting should be used for the specified file and `false` otherwise. | `false` | | ## LSP Declaration Feature diff --git a/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPCompletionFeature.java b/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPCompletionFeature.java index 4ff9bf324..bc835496f 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPCompletionFeature.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPCompletionFeature.java @@ -97,7 +97,7 @@ public boolean isCompletionSupported(@NotNull PsiFile file) { * @param file the file. * @return true the file associated with a language server can support resolve completion and false otherwise. */ - public boolean isResolveCompletionSupported(@Nullable PsiFile file) { + public boolean isResolveCompletionSupported(@NotNull PsiFile file) { return getCompletionCapabilityRegistry().isResolveCompletionSupported(file); } @@ -183,7 +183,7 @@ public Icon getIcon(@NotNull CompletionItem item) { /** * Returns true if the IntelliJ lookup is strike out and false otherwise. * - * @param item + * @param item the completion item * @return true if the IntelliJ lookup is strike out and false otherwise. */ public boolean isStrikeout(@NotNull CompletionItem item) { @@ -216,12 +216,12 @@ public boolean isItemTextBold(@NotNull CompletionItem item) { /** * Don't override this method, we need to revisit the API and the prefix computation (to customize it). * - * @param context - * @param completionPrefix - * @param result - * @param lookupItem - * @param priority - * @param item + * @param context the completion context + * @param completionPrefix the completion prefix + * @param result the completion result set + * @param lookupItem the lookup item + * @param priority the completion priority + * @param item the completion item */ @ApiStatus.Internal public void addLookupItem(@NotNull LSPCompletionContext context, @@ -249,12 +249,13 @@ public void addLookupItem(@NotNull LSPCompletionContext context, .addElement(prioritizedLookupItem); } } else { - // Should happen rarely, only when text edit is for multi-lines or if completion is triggered outside the text edit range. - // Add the IJ completion item (lookup item) which will use the IJ prefix respecting the language's case-sensitivity + // Add the IJ completion item (lookup item) by using the prefix matcher respecting the language's case-sensitivity if (caseSensitive) { - result.addElement(prioritizedLookupItem); + result.withPrefixMatcher(result.getPrefixMatcher()) + .addElement(prioritizedLookupItem); } else { - result.caseInsensitive() + result.withPrefixMatcher(result.getPrefixMatcher()) + .caseInsensitive() .addElement(prioritizedLookupItem); } } @@ -294,4 +295,15 @@ public void setServerCapabilities(@Nullable ServerCapabilities serverCapabilitie completionCapabilityRegistry.setServerCapabilities(serverCapabilities); } } + + /** + * Determines whether or not client-side context-aware completion sorting should be used for the specified file. + * + * @param file the file + * @return true if client-side context-aware completion sorting should be used; otherwise false + */ + public boolean useContextAwareSorting(@NotNull PsiFile file) { + // Default to disabled + return false; + } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/completion/CompletionItemComparator.java b/src/main/java/com/redhat/devtools/lsp4ij/features/completion/CompletionItemComparator.java index 1763de2ee..1e4e7aa7c 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/completion/CompletionItemComparator.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/completion/CompletionItemComparator.java @@ -10,15 +10,30 @@ ******************************************************************************/ package com.redhat.devtools.lsp4ij.features.completion; -import java.util.Comparator; - +import com.intellij.codeInsight.completion.PrefixMatcher; +import com.intellij.openapi.util.text.StringUtil; import org.eclipse.lsp4j.CompletionItem; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.util.Comparator; + /** * Compares {@link CompletionItem}s by their sortText property (falls back to comparing labels) */ public class CompletionItemComparator implements Comparator { + private final PrefixMatcher prefixMatcher; + private final String currentWord; + private final boolean caseSensitive; + + public CompletionItemComparator(@Nullable PrefixMatcher prefixMatcher, + @Nullable String currentWord, + boolean caseSensitive) { + this.prefixMatcher = prefixMatcher; + this.currentWord = currentWord; + this.caseSensitive = caseSensitive; + } + @Override public int compare(CompletionItem item1, CompletionItem item2) { if (item1 == item2) { @@ -29,24 +44,104 @@ public int compare(CompletionItem item1, CompletionItem item2) { return 1; } - int comparison = compareNullable(item1.getSortText(), item2.getSortText()); + // If one is a better match for the current word than the other, sort it higher + int comparison = compareAgainstCurrentWord(item1, item2); + if (comparison != 0) { + return comparison; + } + + // If one is a better completion for the current prefix than the other, sort it higher + comparison = compareAgainstPrefix(item1, item2); + if (comparison != 0) { + return comparison; + } + + // Order by language server-provided sort text + comparison = compare(item1.getSortText(), item2.getSortText()); + if (comparison != 0) { + return comparison; + } // If sortText is equal, fall back to comparing labels - if (comparison == 0) { - comparison = item1.getLabel().compareTo(item2.getLabel()); + return compare(item1.getLabel(), item2.getLabel()); + } + + private int compare(@Nullable String string1, @Nullable String string2) { + return StringUtil.compare(string1, string2, !caseSensitive); + } + + private boolean equals(@Nullable String string1, @Nullable String string2) { + return StringUtil.compare(string1, string2, !caseSensitive) == 0; + } + + private boolean startsWith(@Nullable String string, @Nullable String prefix) { + if ((string == null) || (prefix == null)) { + return false; } + return caseSensitive ? StringUtil.startsWith(string, prefix) : StringUtil.startsWithIgnoreCase(string, prefix); + } + + private int compareAgainstCurrentWord(@NotNull CompletionItem item1, @NotNull CompletionItem item2) { + if (currentWord != null) { + String label1 = item1.getLabel(); + String label2 = item2.getLabel(); + // Don't do this for completion offerings that are quoted strings + if (((label1 == null) || !StringUtil.isQuotedString(label1)) && + ((label2 == null) || !StringUtil.isQuotedString(label2))) { + // Exact match + if (equals(currentWord, label1) && + ((label2 == null) || !equals(currentWord, label2))) { + return -1; + } else if (equals(currentWord, label2) && + ((label1 == null) || !equals(currentWord, label1))) { + return 1; + } - return comparison; + // Starts with + else if ((startsWith(currentWord, label1) || startsWith(label1, currentWord)) && + ((label2 == null) || !(startsWith(currentWord, label2) || startsWith(label2, currentWord)))) { + return -1; + } else if ((startsWith(currentWord, label2) || startsWith(label2, currentWord)) && + ((label1 == null) || !(startsWith(currentWord, label1) || startsWith(label1, currentWord)))) { + return 1; + } + } + } + + return 0; } - private int compareNullable(@Nullable String s1, @Nullable String s2) { - if (s1 == s2) { - return 0; - } else if (s1 == null) { - return -1; - } else if (s2 == null) { - return 1; + private int compareAgainstPrefix(@NotNull CompletionItem item1, @NotNull CompletionItem item2) { + if (prefixMatcher != null) { + String prefix = prefixMatcher.getPrefix(); + String label1 = item1.getLabel(); + String label2 = item2.getLabel(); + // Don't do this for completion offerings that are quoted strings + if (((label1 == null) || !StringUtil.isQuotedString(label1)) && + ((label2 == null) || !StringUtil.isQuotedString(label2))) { + // Start starts with + if (startsWith(label1, prefix) && + (!startsWith(label2, prefix))) { + return -1; + } else if (startsWith(label2, prefix) && + (!startsWith(label1, prefix))) { + return 1; + } + // Loose/camel-hump starts with + else if ((label1 != null) && prefixMatcher.isStartMatch(label1) && + ((label2 == null) || !prefixMatcher.isStartMatch(label2))) { + return -1; + } else if ((label2 != null) && prefixMatcher.isStartMatch(label2) && + ((label1 == null) || !prefixMatcher.isStartMatch(label1))) { + return 1; + } else if ((label1 != null) && prefixMatcher.isStartMatch(label1) && + ((label2 != null) && prefixMatcher.isStartMatch(label2))) { + // Better matches are ranked higher and we want those ordered earlier + return prefixMatcher.matchingDegree(label2) - prefixMatcher.matchingDegree(label1); + } + } } - return s1.compareTo(s2); + + return 0; } } \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/completion/LSPCompletionContributor.java b/src/main/java/com/redhat/devtools/lsp4ij/features/completion/LSPCompletionContributor.java index 3b34f462e..7a7bd5eb5 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/completion/LSPCompletionContributor.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/completion/LSPCompletionContributor.java @@ -17,14 +17,17 @@ import com.intellij.openapi.editor.Editor; import com.intellij.openapi.progress.ProcessCanceledException; import com.intellij.openapi.progress.ProgressManager; +import com.intellij.openapi.util.TextRange; import com.intellij.openapi.util.UserDataHolder; import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.patterns.StandardPatterns; import com.intellij.psi.PsiFile; import com.intellij.util.containers.ContainerUtil; import com.redhat.devtools.lsp4ij.LSPFileSupport; import com.redhat.devtools.lsp4ij.LSPIJUtils; import com.redhat.devtools.lsp4ij.LanguageServerItem; import com.redhat.devtools.lsp4ij.client.ExecuteLSPFeatureStatus; +import com.redhat.devtools.lsp4ij.client.features.LSPClientFeatures; import com.redhat.devtools.lsp4ij.client.features.LSPCompletionFeature; import com.redhat.devtools.lsp4ij.client.features.LSPCompletionProposal; import com.redhat.devtools.lsp4ij.client.indexing.ProjectIndexingManager; @@ -102,8 +105,6 @@ public void fillCompletionVariants(@NotNull CompletionParameters parameters, @No } } - private static final CompletionItemComparator completionProposalComparator = new CompletionItemComparator(); - private void addCompletionItems(@NotNull CompletionParameters parameters, @NotNull CompletionPrefix completionPrefix, @NotNull Either, CompletionList> completion, @@ -119,12 +120,23 @@ private void addCompletionItems(@NotNull CompletionParameters parameters, items.addAll(completionList.getItems()); } - // Sort by item.sortText - items.sort(completionProposalComparator); + PsiFile originalFile = parameters.getOriginalFile(); + LSPClientFeatures clientFeatures = languageServer.getClientFeatures(); + + // Sort the completions as appropriate based on client configuration + boolean useContextAwareSorting = clientFeatures.getCompletionFeature().useContextAwareSorting(originalFile); + if (useContextAwareSorting) { + // Cache-buster for prefix changes since that can affect ordering + result.restartCompletionOnPrefixChange(StandardPatterns.string().longerThan(0)); + } + PrefixMatcher prefixMatcher = useContextAwareSorting ? result.getPrefixMatcher() : null; + String currentWord = useContextAwareSorting ? getCurrentWord(parameters) : null; + boolean caseSensitive = clientFeatures.isCaseSensitive(originalFile); + items.sort(new CompletionItemComparator(prefixMatcher, currentWord, caseSensitive)); int size = items.size(); Set addedLookupStrings = new HashSet<>(); - var completionFeature = languageServer.getClientFeatures().getCompletionFeature(); + var completionFeature = clientFeatures.getCompletionFeature(); LSPCompletionFeature.LSPCompletionContext context = new LSPCompletionFeature.LSPCompletionContext(parameters, languageServer); // Items now sorted by priority, low index == high priority for (int i = 0; i < size; i++) { @@ -186,6 +198,23 @@ private void addCompletionItems(@NotNull CompletionParameters parameters, } } + @Nullable + private static String getCurrentWord(@NotNull CompletionParameters parameters) { + PsiFile originalFile = parameters.getOriginalFile(); + VirtualFile virtualFile = originalFile.getVirtualFile(); + Document document = virtualFile != null ? LSPIJUtils.getDocument(virtualFile) : null; + if (document != null) { + int offset = parameters.getOffset(); + TextRange wordTextRange = LSPIJUtils.getWordRangeAt(document, originalFile, offset); + if (wordTextRange != null) { + CharSequence documentChars = document.getCharsSequence(); + CharSequence wordChars = documentChars.subSequence(wordTextRange.getStartOffset(), wordTextRange.getEndOffset()); + return wordChars.toString(); + } + } + return null; + } + protected void updateWithItemDefaults(@NotNull CompletionItem item, @Nullable CompletionItemDefaults itemDefaults) { if (itemDefaults == null) { diff --git a/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/ClientConfigurationSettings.java b/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/ClientConfigurationSettings.java index f3e9b15f7..82351b77a 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/ClientConfigurationSettings.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/ClientConfigurationSettings.java @@ -20,7 +20,17 @@ */ public class ClientConfigurationSettings { /** - * Client-side code workspace symbol settings. + * Client-side code completion settings. + */ + public static class ClientConfigurationCompletionSettings { + /** + * Whether or not client-side context-aware completion sorting should be used. Defaults to false. + */ + public boolean useContextAwareSorting = false; + } + + /** + * Client-side workspace symbol settings. */ public static class ClientConfigurationWorkspaceSymbolSettings { /** @@ -35,7 +45,12 @@ public static class ClientConfigurationWorkspaceSymbolSettings { public boolean caseSensitive = false; /** - * Client-side code workspace symbol settings + * Client-side code completion settings + */ + public @NotNull ClientConfigurationCompletionSettings completion = new ClientConfigurationCompletionSettings(); + + /** + * Client-side workspace symbol settings */ public @NotNull ClientConfigurationWorkspaceSymbolSettings workspaceSymbol = new ClientConfigurationWorkspaceSymbolSettings(); } \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedClientFeatures.java b/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedClientFeatures.java index 093294da3..3ae59b3a1 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedClientFeatures.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedClientFeatures.java @@ -26,6 +26,7 @@ public UserDefinedClientFeatures() { super(); // Use the extended feature implementations + setCompletionFeature(new UserDefinedCompletionFeature()); setWorkspaceSymbolFeature(new UserDefinedWorkspaceSymbolFeature()); } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedCompletionFeature.java b/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedCompletionFeature.java new file mode 100644 index 000000000..051094ac7 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedCompletionFeature.java @@ -0,0 +1,31 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.server.definition.launching; + +import com.intellij.psi.PsiFile; +import com.redhat.devtools.lsp4ij.client.features.LSPCompletionFeature; +import org.jetbrains.annotations.NotNull; + +/** + * Adds client-side code completion configuration features. + */ +public class UserDefinedCompletionFeature extends LSPCompletionFeature { + + @Override + public boolean useContextAwareSorting(@NotNull PsiFile file) { + UserDefinedLanguageServerDefinition serverDefinition = (UserDefinedLanguageServerDefinition) getClientFeatures().getServerDefinition(); + ClientConfigurationSettings clientConfiguration = serverDefinition.getLanguageServerClientConfiguration(); + return clientConfiguration != null ? clientConfiguration.completion.useContextAwareSorting : super.useContextAwareSorting(file); + } +} \ No newline at end of file diff --git a/src/main/resources/jsonSchema/clientSettings.schema.json b/src/main/resources/jsonSchema/clientSettings.schema.json index 907db4f3f..5d858af0c 100644 --- a/src/main/resources/jsonSchema/clientSettings.schema.json +++ b/src/main/resources/jsonSchema/clientSettings.schema.json @@ -11,6 +11,19 @@ "description": "Whether or not the language grammar is case-sensitive.", "default": false }, + "completion": { + "type": "object", + "title": "Client-side completion configuration", + "additionalProperties": false, + "properties": { + "useContextAwareSorting": { + "type": "boolean", + "title": "Use context-aware completion sorting", + "description": "Whether or not client-side context-aware completion sorting should be used.", + "default": false + } + } + }, "workspaceSymbol": { "type": "object", "title": "Client-side workspace symbol configuration", diff --git a/src/main/resources/templates/clojure-lsp/clientSettings.json b/src/main/resources/templates/clojure-lsp/clientSettings.json index 2a717ac8d..c4da8c8f2 100644 --- a/src/main/resources/templates/clojure-lsp/clientSettings.json +++ b/src/main/resources/templates/clojure-lsp/clientSettings.json @@ -1,5 +1,8 @@ { "caseSensitive": true, + "completion": { + "useContextAwareSorting": true + }, "workspaceSymbol": { "supportsGotoClass": true } diff --git a/src/main/resources/templates/gopls/clientSettings.json b/src/main/resources/templates/gopls/clientSettings.json index 2a717ac8d..c4da8c8f2 100644 --- a/src/main/resources/templates/gopls/clientSettings.json +++ b/src/main/resources/templates/gopls/clientSettings.json @@ -1,5 +1,8 @@ { "caseSensitive": true, + "completion": { + "useContextAwareSorting": true + }, "workspaceSymbol": { "supportsGotoClass": true } diff --git a/src/main/resources/templates/jdtls/clientSettings.json b/src/main/resources/templates/jdtls/clientSettings.json index 2a717ac8d..c4da8c8f2 100644 --- a/src/main/resources/templates/jdtls/clientSettings.json +++ b/src/main/resources/templates/jdtls/clientSettings.json @@ -1,5 +1,8 @@ { "caseSensitive": true, + "completion": { + "useContextAwareSorting": true + }, "workspaceSymbol": { "supportsGotoClass": true } diff --git a/src/main/resources/templates/lemminx/clientSettings.json b/src/main/resources/templates/lemminx/clientSettings.json index b3239a2d2..b920e6913 100644 --- a/src/main/resources/templates/lemminx/clientSettings.json +++ b/src/main/resources/templates/lemminx/clientSettings.json @@ -1,5 +1,8 @@ { "caseSensitive": true, + "completion": { + "useContextAwareSorting": true + }, "workspaceSymbol": { "supportsGotoClass": false } diff --git a/src/main/resources/templates/metals/clientSettings.json b/src/main/resources/templates/metals/clientSettings.json index 2a717ac8d..c4da8c8f2 100644 --- a/src/main/resources/templates/metals/clientSettings.json +++ b/src/main/resources/templates/metals/clientSettings.json @@ -1,5 +1,8 @@ { "caseSensitive": true, + "completion": { + "useContextAwareSorting": true + }, "workspaceSymbol": { "supportsGotoClass": true } diff --git a/src/main/resources/templates/rust-analyzer/clientSettings.json b/src/main/resources/templates/rust-analyzer/clientSettings.json index 2a717ac8d..c4da8c8f2 100644 --- a/src/main/resources/templates/rust-analyzer/clientSettings.json +++ b/src/main/resources/templates/rust-analyzer/clientSettings.json @@ -1,5 +1,8 @@ { "caseSensitive": true, + "completion": { + "useContextAwareSorting": true + }, "workspaceSymbol": { "supportsGotoClass": true } diff --git a/src/main/resources/templates/sourcekit-lsp/clientSettings.json b/src/main/resources/templates/sourcekit-lsp/clientSettings.json index 2a717ac8d..c4da8c8f2 100644 --- a/src/main/resources/templates/sourcekit-lsp/clientSettings.json +++ b/src/main/resources/templates/sourcekit-lsp/clientSettings.json @@ -1,5 +1,8 @@ { "caseSensitive": true, + "completion": { + "useContextAwareSorting": true + }, "workspaceSymbol": { "supportsGotoClass": true } diff --git a/src/main/resources/templates/typescript-language-server/clientSettings.json b/src/main/resources/templates/typescript-language-server/clientSettings.json index 2a717ac8d..c4da8c8f2 100644 --- a/src/main/resources/templates/typescript-language-server/clientSettings.json +++ b/src/main/resources/templates/typescript-language-server/clientSettings.json @@ -1,5 +1,8 @@ { "caseSensitive": true, + "completion": { + "useContextAwareSorting": true + }, "workspaceSymbol": { "supportsGotoClass": true } diff --git a/src/main/resources/templates/vscode-css-language-server/clientSettings.json b/src/main/resources/templates/vscode-css-language-server/clientSettings.json index b3239a2d2..b920e6913 100644 --- a/src/main/resources/templates/vscode-css-language-server/clientSettings.json +++ b/src/main/resources/templates/vscode-css-language-server/clientSettings.json @@ -1,5 +1,8 @@ { "caseSensitive": true, + "completion": { + "useContextAwareSorting": true + }, "workspaceSymbol": { "supportsGotoClass": false } diff --git a/src/main/resources/templates/vscode-html-language-server/clientSettings.json b/src/main/resources/templates/vscode-html-language-server/clientSettings.json index 00d4fb8df..7903033e7 100644 --- a/src/main/resources/templates/vscode-html-language-server/clientSettings.json +++ b/src/main/resources/templates/vscode-html-language-server/clientSettings.json @@ -1,5 +1,8 @@ { "caseSensitive": false, + "completion": { + "useContextAwareSorting": true + }, "workspaceSymbol": { "supportsGotoClass": false } diff --git a/src/test/java/com/redhat/devtools/lsp4ij/features/completion/CompletionItemComparatorTest.java b/src/test/java/com/redhat/devtools/lsp4ij/features/completion/CompletionItemComparatorTest.java index 5f2f2b7c3..1251f1446 100644 --- a/src/test/java/com/redhat/devtools/lsp4ij/features/completion/CompletionItemComparatorTest.java +++ b/src/test/java/com/redhat/devtools/lsp4ij/features/completion/CompletionItemComparatorTest.java @@ -8,9 +8,16 @@ * Contributors: * Red Hat, Inc. - initial API and implementation ******************************************************************************/ + package com.redhat.devtools.lsp4ij.features.completion; +import com.intellij.codeInsight.completion.PrefixMatcher; +import com.intellij.psi.codeStyle.MinusculeMatcher; +import com.intellij.psi.codeStyle.NameUtil; +import com.intellij.psi.codeStyle.NameUtil.MatchingCaseSensitivity; import org.eclipse.lsp4j.CompletionItem; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.junit.Test; import java.util.ArrayList; @@ -19,46 +26,262 @@ import static org.junit.jupiter.api.Assertions.*; public class CompletionItemComparatorTest { - private final CompletionItemComparator comparator = new CompletionItemComparator(); - private final CompletionItem one = newItem("one", "1"); - private final CompletionItem nil = newItem("", null); - private final CompletionItem two = newItem("two", "2"); - private final CompletionItem three = newItem("three", "3"); - private final CompletionItem firstthree = newItem("first three", "3"); + private static final CompletionItemComparator caseInsensitiveComparator = new CompletionItemComparator(null, null, false); + private static final CompletionItemComparator caseSensitiveComparator = new CompletionItemComparator(null, null, true); + + // Simple tests + + private static final CompletionItem one = newItem("one", "1"); + private static final CompletionItem nil = newItem("", null); + private static final CompletionItem two = newItem("two", "2"); + private static final CompletionItem three = newItem("three", "3"); + private static final CompletionItem firstthree = newItem("first three", "3"); @Test public void orderList() { var items = new ArrayList<>(List.of(three, one, nil, two, firstthree)); var expected = List.of(nil, one, two, firstthree, three); - items.sort(comparator); + items.sort(caseInsensitiveComparator); assertEquals(expected, items); } @Test public void compareSortText() { - assertTrue(comparator.compare(one, two) < 0); - assertTrue(comparator.compare(two, one) > 0); + assertTrue(caseInsensitiveComparator.compare(one, two) < 0); + assertTrue(caseInsensitiveComparator.compare(two, one) > 0); } @Test public void compareLabels() { - assertTrue(comparator.compare(newItem("one", null), newItem("two", null)) < 0); - assertTrue(comparator.compare(newItem("two", null), newItem("three", null)) > 0); + assertTrue(caseInsensitiveComparator.compare(newItem("one", null), newItem("two", null)) < 0); + assertTrue(caseInsensitiveComparator.compare(newItem("two", null), newItem("three", null)) > 0); } @Test public void compareNulls() { - assertEquals(1,comparator.compare(one, null)); - assertEquals(0,comparator.compare(nil, nil)); - assertEquals(1,comparator.compare(nil, null)); - assertEquals(0,comparator.compare(null, null)); + assertEquals(1, caseInsensitiveComparator.compare(one, null)); + assertEquals(0, caseInsensitiveComparator.compare(nil, nil)); + assertEquals(1, caseInsensitiveComparator.compare(nil, null)); + assertEquals(0, caseInsensitiveComparator.compare(null, null)); + } + + // Case-sensitivity tests + + private static final CompletionItem lowerCaseTestItem = newItem("name", null); + private static final CompletionItem upperCaseTestItem = newItem("NAME", null); + private static final CompletionItem capitalizedTestItem = newItem("Name", null); + private static final List caseSensitivityTestItems = List.of( + lowerCaseTestItem, + upperCaseTestItem, + capitalizedTestItem + ); + + @Test + public void compareLabelsCaseInsensitive() { + List items = new ArrayList<>(caseSensitivityTestItems); + items.sort(caseInsensitiveComparator); + // Should be no change in order + assertSortOrder(items, caseSensitivityTestItems.toArray(new CompletionItem[0])); } - private CompletionItem newItem(String label, String sortText) { + @Test + public void compareLabelsCaseSensitive() { + List items = new ArrayList<>(caseSensitivityTestItems); + items.sort(caseSensitiveComparator); + assertSortOrder(items, upperCaseTestItem, capitalizedTestItem, lowerCaseTestItem); + } + + // Prefix tests + + private static final CompletionItem fooItem = newItem("foo", null); + private static final CompletionItem barItem = newItem("bar", null); + private static final CompletionItem bazItem = newItem("Baz", null); + private static final CompletionItem feItem = newItem("Fe", null); + private static final CompletionItem fiItem = newItem("FI", null); + private static final CompletionItem foItem = newItem("fo", null); + private static final CompletionItem fumItem = newItem("Fum", null); + private static final List items = List.of(fooItem, barItem, bazItem, feItem, fiItem, foItem, fumItem); + + @Test + public void compareLabelsAgainstPrefixCaseInsensitive() { + boolean caseSensitive = false; + + // First with a single lower-cased letter + List mutableItems = new ArrayList<>(items); + PrefixMatcher prefixMatcher = createPrefixMatcher("f", caseSensitive); + CompletionItemComparator comparator = new CompletionItemComparator(prefixMatcher, null, caseSensitive); + mutableItems.sort(comparator); + assertSortOrder(mutableItems, foItem, fooItem, feItem, fiItem, fumItem, barItem, bazItem); + + // Then with a single upper-cased letter which should yield the exact same results + mutableItems = new ArrayList<>(items); + prefixMatcher = createPrefixMatcher("F", caseSensitive); + comparator = new CompletionItemComparator(prefixMatcher, null, caseSensitive); + mutableItems.sort(comparator); + assertSortOrder(mutableItems, feItem, fiItem, fumItem, foItem, fooItem, barItem, bazItem); + + // Then with a second letter with mixed case + mutableItems = new ArrayList<>(items); + prefixMatcher = createPrefixMatcher("fO", caseSensitive); + comparator = new CompletionItemComparator(prefixMatcher, null, caseSensitive); + mutableItems.sort(comparator); + assertSortOrder(mutableItems, foItem, fooItem, barItem, bazItem, feItem, fiItem, fumItem); + } + + @Test + public void compareLabelsAgainstPrefixCaseSensitive() { + boolean caseSensitive = true; + + // First with a single lower-cased letter + List mutableItems = new ArrayList<>(items); + PrefixMatcher prefixMatcher = createPrefixMatcher("f", caseSensitive); + CompletionItemComparator comparator = new CompletionItemComparator(prefixMatcher, null, caseSensitive); + mutableItems.sort(comparator); + assertSortOrder(mutableItems, foItem, fooItem, bazItem, fiItem, feItem, fumItem, barItem); + + // Then with a single upper-cased letter + mutableItems = new ArrayList<>(items); + prefixMatcher = createPrefixMatcher("F", caseSensitive); + comparator = new CompletionItemComparator(prefixMatcher, null, caseSensitive); + mutableItems.sort(comparator); + assertSortOrder(mutableItems, fiItem, feItem, fumItem, bazItem, barItem, foItem, fooItem); + + // Then with a second letter with mixed case + mutableItems = new ArrayList<>(items); + prefixMatcher = createPrefixMatcher("Fe", caseSensitive); + comparator = new CompletionItemComparator(prefixMatcher, null, caseSensitive); + mutableItems.sort(comparator); + assertSortOrder(mutableItems, feItem, bazItem, fiItem, fumItem, barItem, foItem, fooItem); + } + + @Test + public void compareLabelsAgainstComplexPrefixCaseSensitive() { + boolean caseSensitive = true; + + CompletionItem toLocaleLowerCaseItem = newItem("toLocaleLowerCase", null); + CompletionItem toLocaleUpperCaseItem = newItem("toLocaleUpperCase", null); + CompletionItem toLowerCaseItem = newItem("toLowerCase", null); + CompletionItem toStringItem = newItem("toString", null); + CompletionItem toUpperCaseItem = newItem("toUpperCase", null); + List items = List.of(toLocaleLowerCaseItem, toLocaleUpperCaseItem, toLowerCaseItem, toStringItem, toUpperCaseItem); + + // First with a prefix of "to" + List mutableItems = new ArrayList<>(items); + PrefixMatcher prefixMatcher = createPrefixMatcher("to", caseSensitive); + CompletionItemComparator comparator = new CompletionItemComparator(prefixMatcher, null, caseSensitive); + mutableItems.sort(comparator); + assertSortOrder(mutableItems, toLocaleLowerCaseItem, toLocaleUpperCaseItem, toLowerCaseItem, toStringItem, toUpperCaseItem); + + // Then with a prefix of "toU" + mutableItems = new ArrayList<>(items); + prefixMatcher = createPrefixMatcher("toU", caseSensitive); + comparator = new CompletionItemComparator(prefixMatcher, null, caseSensitive); + mutableItems.sort(comparator); + assertSortOrder(mutableItems, toUpperCaseItem, toLocaleUpperCaseItem, toLocaleLowerCaseItem, toLowerCaseItem, toStringItem); + + // Then with a prefix of "toUC" + mutableItems = new ArrayList<>(items); + prefixMatcher = createPrefixMatcher("toUC", caseSensitive); + comparator = new CompletionItemComparator(prefixMatcher, null, caseSensitive); + mutableItems.sort(comparator); + assertSortOrder(mutableItems, toUpperCaseItem, toLocaleUpperCaseItem, toLocaleLowerCaseItem, toLowerCaseItem, toStringItem); + } + + // Current word tests + + @Test + public void compareLabelsAgainstCurrentWordCaseInsensitive() { + boolean caseSensitive = false; + + List mutableItems = new ArrayList<>(items); + // Use a different case and confirm that it still works properly + CompletionItemComparator comparator = new CompletionItemComparator(null, bazItem.getLabel().toLowerCase(), caseSensitive); + mutableItems.sort(comparator); + assertSortOrder(mutableItems, bazItem, barItem, feItem, fiItem, foItem, fooItem, fumItem); + } + + @Test + public void compareLabelsAgainstCurrentWordCaseSensitive() { + boolean caseSensitive = true; + + // First confirm that it matches with the same case + List mutableItems = new ArrayList<>(items); + CompletionItemComparator comparator = new CompletionItemComparator(null, fumItem.getLabel(), caseSensitive); + mutableItems.sort(comparator); + assertSortOrder(mutableItems, fumItem, bazItem, fiItem, feItem, barItem, foItem, fooItem); + + // Next confirm that it doesn't match with a different case + mutableItems = new ArrayList<>(items); + comparator = new CompletionItemComparator(null, fumItem.getLabel().toLowerCase(), caseSensitive); + mutableItems.sort(comparator); + assertSortOrder(mutableItems, bazItem, fiItem, feItem, fumItem, barItem, foItem, fooItem); + } + + // Prefix and current word tests + + @Test + public void compareLabelsAgainstPrefixAndCurrentWordCaseInsensitive() { + boolean caseSensitive = false; + + List mutableItems = new ArrayList<>(items); + PrefixMatcher prefixMatcher = createPrefixMatcher(foItem.getLabel().toUpperCase(), caseSensitive); + CompletionItemComparator comparator = new CompletionItemComparator(prefixMatcher, fooItem.getLabel().toUpperCase(), caseSensitive); + mutableItems.sort(comparator); + assertSortOrder(mutableItems, fooItem, foItem, barItem, bazItem, feItem, fiItem, fumItem); + } + + @Test + public void compareLabelsAgainstPrefixAndCurrentWordCaseSensitive() { + boolean caseSensitive = true; + + List mutableItems = new ArrayList<>(items); + PrefixMatcher prefixMatcher = createPrefixMatcher(foItem.getLabel(), caseSensitive); + CompletionItemComparator comparator = new CompletionItemComparator(prefixMatcher, fooItem.getLabel(), caseSensitive); + mutableItems.sort(comparator); + assertSortOrder(mutableItems, fooItem, foItem, bazItem, fiItem, feItem, fumItem, barItem); + } + + // Test utilities + + private static CompletionItem newItem(@Nullable String label, @Nullable String sortText) { CompletionItem item = new CompletionItem(label); item.setSortText(sortText); return item; } + private static void assertSortOrder(@NotNull List actualItems, @NotNull CompletionItem... expectedItems) { + assertEquals(expectedItems.length, actualItems.size(), "The two lists have different lengths."); + for (int i = 0; i < expectedItems.length; i++) { + assertEquals(expectedItems[i], actualItems.get(i), "The items at index " + i + " are different."); + } + } + + @NotNull + private static PrefixMatcher createPrefixMatcher(@NotNull String prefix, boolean caseSensitive) { + // NOTE: This allows us to avoid a dependency on the application while running these tests + MinusculeMatcher minusculeMatcher = NameUtil + .buildMatcher(prefix) + .withCaseSensitivity(caseSensitive ? MatchingCaseSensitivity.FIRST_LETTER : MatchingCaseSensitivity.NONE) + .build(); + return new PrefixMatcher(prefix) { + @Override + public boolean prefixMatches(@NotNull String name) { + return minusculeMatcher.isStartMatch(name); + } + + @Override + public int matchingDegree(String string) { + return minusculeMatcher.matchingDegree(string); + } + + @Override + @NotNull + public PrefixMatcher cloneWithPrefix(@NotNull String prefix) { + fail("This matcher should not be cloned."); + // Have to return something for the compiler + return this; + } + }; + } } \ No newline at end of file