diff --git a/bundles/org.eclipse.ui.workbench/eclipseui/org/eclipse/ui/internal/quickaccess/QuickAccessMatcher.java b/bundles/org.eclipse.ui.workbench/eclipseui/org/eclipse/ui/internal/quickaccess/QuickAccessMatcher.java index c3a32ac9feb..fa6f27957e9 100644 --- a/bundles/org.eclipse.ui.workbench/eclipseui/org/eclipse/ui/internal/quickaccess/QuickAccessMatcher.java +++ b/bundles/org.eclipse.ui.workbench/eclipseui/org/eclipse/ui/internal/quickaccess/QuickAccessMatcher.java @@ -16,7 +16,6 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; -import java.util.regex.PatternSyntaxException; import org.eclipse.ui.quickaccess.QuickAccessElement; /** @@ -34,29 +33,15 @@ public QuickAccessMatcher(QuickAccessElement element) { } private static final int[][] EMPTY_INDICES = new int[0][0]; - private static final String WS_WILD_START = "^\\s*(\\*|\\?)*"; //$NON-NLS-1$ - private static final String WS_WILD_END = "(\\*|\\?)*\\s*$"; //$NON-NLS-1$ - private static final String ANY_WS = "\\s+"; //$NON-NLS-1$ - private static final String EMPTY_STR = ""; //$NON-NLS-1$ - private static final String PAR_START = "\\("; //$NON-NLS-1$ - private static final String PAR_END = "\\)"; //$NON-NLS-1$ - private static final String ONE_CHAR = ".?"; //$NON-NLS-1$ // whitespaces filter and patterns private String wsFilter; private Pattern wsPattern; - /** - * Get the existing {@link Pattern} for the given filter, or create a new one. - * The generated pattern will replace whitespace with * to match all. - */ private Pattern getWhitespacesPattern(String filter) { if (wsPattern == null || !filter.equals(wsFilter)) { wsFilter = filter; - String sFilter = filter.replaceFirst(WS_WILD_START, EMPTY_STR).replaceFirst(WS_WILD_END, EMPTY_STR) - .replaceAll(PAR_START, ONE_CHAR).replaceAll(PAR_END, ONE_CHAR); - sFilter = String.format(".*(%s).*", sFilter.replaceAll(ANY_WS, ").*(")); //$NON-NLS-1$//$NON-NLS-2$ - wsPattern = safeCompile(sFilter); + wsPattern = QuickAccessMatching.whitespacesPattern(filter); } return wsPattern; } @@ -65,63 +50,15 @@ private Pattern getWhitespacesPattern(String filter) { private String wcFilter; private Pattern wcPattern; - /** - * Get the existing {@link Pattern} for the given filter, or create a new one. - * The generated pattern will handle '*' and '?' wildcards. - */ private Pattern getWildcardsPattern(String filter) { - // squash consecutive **** into a single * - filter = filter.replaceAll("\\*+", "*"); //$NON-NLS-1$ //$NON-NLS-2$ - if (wcPattern == null || !filter.equals(wcFilter)) { - wcFilter = filter; - String sFilter = filter.replaceFirst(WS_WILD_START, EMPTY_STR).replaceFirst(WS_WILD_END, EMPTY_STR) - .replaceAll(PAR_START, ONE_CHAR).replaceAll(PAR_END, ONE_CHAR); - // replace '*' and '?' with their matchers ").*(" and ").?(" - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < sFilter.length(); i++) { - char c = sFilter.charAt(i); - if (c == '*') { - sb.append(").").append(c).append("("); //$NON-NLS-1$ //$NON-NLS-2$ - } else if (c == '?') { - int n = 1; - for (; (i + 1) < sFilter.length(); i++) { - if (sFilter.charAt(i + 1) != '?') { - break; - } - n++; - } - sb.append(").").append(n == 1 ? '?' : String.format("{0,%d}", n)).append("("); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ - } else { - sb.append(c); - } - } - sFilter = String.format(".*(%s).*", sb.toString()); //$NON-NLS-1$ - // remove empty capturing groups - sFilter = sFilter.replace("()", EMPTY_STR); //$NON-NLS-1$ - // - wcPattern = safeCompile(sFilter); + String squashed = filter.replaceAll("\\*+", "*"); //$NON-NLS-1$ //$NON-NLS-2$ + if (wcPattern == null || !squashed.equals(wcFilter)) { + wcFilter = squashed; + wcPattern = QuickAccessMatching.wildcardsPattern(squashed); } return wcPattern; } - /** - * A safe way to compile some unknown pattern, avoids possible - * {@link PatternSyntaxException}. If the pattern can't be compiled, some not - * matching pattern will be returned. - * - * @param pattern some pattern to compile, not null - * @return a {@link Pattern} object compiled from given input or a dummy pattern - * which do not match anything - */ - private static Pattern safeCompile(String pattern) { - try { - return Pattern.compile(pattern, Pattern.CASE_INSENSITIVE); - } catch (Exception e) { - // A "bell" special character: should not match anything we can get - return Pattern.compile("\\a"); //$NON-NLS-1$ - } - } - /** * If this element is a match (partial, complete, camel case, etc) to the given * filter, returns a {@link QuickAccessEntry}. Otherwise returns @@ -134,19 +71,15 @@ private static Pattern safeCompile(String pattern) { */ public QuickAccessEntry match(String filter, QuickAccessProvider providerForMatching) { String matchLabel = element.getMatchLabel(); - // first occurrence of filter - int index = matchLabel.toLowerCase().indexOf(filter); - if (index != -1) { - index = element.getLabel().toLowerCase().indexOf(filter); - if (index != -1) { // match actual label - int quality = matchLabel.toLowerCase().equals(filter) ? QuickAccessEntry.MATCH_PERFECT - : (matchLabel.toLowerCase().startsWith(filter) ? QuickAccessEntry.MATCH_EXCELLENT - : QuickAccessEntry.MATCH_GOOD); - return new QuickAccessEntry(element, providerForMatching, - new int[][] { { index, index + filter.length() - 1 } }, EMPTY_INDICES, quality); + String label = element.getLabel(); + int quality = QuickAccessMatching.substringMatchQuality(matchLabel, label, filter); + if (quality != -1) { + if (quality == QuickAccessEntry.MATCH_PARTIAL) { + return new QuickAccessEntry(element, providerForMatching, EMPTY_INDICES, EMPTY_INDICES, quality); } - return new QuickAccessEntry(element, providerForMatching, EMPTY_INDICES, EMPTY_INDICES, - QuickAccessEntry.MATCH_PARTIAL); + int index = label.toLowerCase().indexOf(filter); + return new QuickAccessEntry(element, providerForMatching, + new int[][] { { index, index + filter.length() - 1 } }, EMPTY_INDICES, quality); } // Pattern p; @@ -161,9 +94,8 @@ public QuickAccessEntry match(String filter, QuickAccessProvider providerForMatc // if matches, return an entry if (m.matches()) { // and highlight match on the label only - String label = element.getLabel(); if (!matchLabel.equals(label)) { - m = p.matcher(element.getLabel()); + m = p.matcher(label); if (!m.matches()) { return new QuickAccessEntry(element, providerForMatching, EMPTY_INDICES, EMPTY_INDICES, QuickAccessEntry.MATCH_GOOD); @@ -176,14 +108,13 @@ public QuickAccessEntry match(String filter, QuickAccessProvider providerForMatc // capturing group indices[i] = new int[] { m.start(nGrp), m.end(nGrp) - 1 }; } - // return match and list of indices - int quality = QuickAccessEntry.MATCH_EXCELLENT; - return new QuickAccessEntry(element, providerForMatching, indices, EMPTY_INDICES, quality); + return new QuickAccessEntry(element, providerForMatching, indices, EMPTY_INDICES, + QuickAccessEntry.MATCH_EXCELLENT); } // - String combinedMatchLabel = (providerForMatching.getName() + " " + element.getMatchLabel()); //$NON-NLS-1$ - String combinedLabel = (providerForMatching.getName() + " " + element.getLabel()); //$NON-NLS-1$ - index = combinedMatchLabel.toLowerCase().indexOf(filter); + String combinedMatchLabel = providerForMatching.getName() + " " + matchLabel; //$NON-NLS-1$ + String combinedLabel = providerForMatching.getName() + " " + label; //$NON-NLS-1$ + int index = combinedMatchLabel.toLowerCase().indexOf(filter); if (index != -1) { // match index = combinedLabel.toLowerCase().indexOf(filter); if (index != -1) { // compute highlight on label @@ -200,7 +131,7 @@ public QuickAccessEntry match(String filter, QuickAccessProvider providerForMatc QuickAccessEntry.MATCH_PARTIAL); } // - String camelCase = CamelUtil.getCamelCase(element.getLabel()); // use actual label for camelcase + String camelCase = CamelUtil.getCamelCase(label); // use actual label for camelcase index = camelCase.indexOf(filter); if (index != -1) { int[][] indices = CamelUtil.getCamelCaseIndices(matchLabel, index, filter.length()); diff --git a/bundles/org.eclipse.ui.workbench/eclipseui/org/eclipse/ui/internal/quickaccess/QuickAccessMatching.java b/bundles/org.eclipse.ui.workbench/eclipseui/org/eclipse/ui/internal/quickaccess/QuickAccessMatching.java new file mode 100644 index 00000000000..05df8faa3e2 --- /dev/null +++ b/bundles/org.eclipse.ui.workbench/eclipseui/org/eclipse/ui/internal/quickaccess/QuickAccessMatching.java @@ -0,0 +1,117 @@ +/******************************************************************************* + * Copyright (c) 2026 Vogella GmbH and others. + * + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ + +package org.eclipse.ui.internal.quickaccess; + +import java.util.regex.Pattern; + +/** + * Pure, side-effect-free helpers used by {@link QuickAccessMatcher}. Extracted + * so the matching/ranking rules can be unit-tested without a workbench harness. + * + * @noreference This class is not intended to be referenced by clients. + */ +public final class QuickAccessMatching { + + private static final String WS_WILD_START = "^\\s*(\\*|\\?)*"; //$NON-NLS-1$ + private static final String WS_WILD_END = "(\\*|\\?)*\\s*$"; //$NON-NLS-1$ + private static final String ANY_WS = "\\s+"; //$NON-NLS-1$ + private static final String EMPTY_STR = ""; //$NON-NLS-1$ + private static final String PAR_START = "\\("; //$NON-NLS-1$ + private static final String PAR_END = "\\)"; //$NON-NLS-1$ + private static final String ONE_CHAR = ".?"; //$NON-NLS-1$ + + private QuickAccessMatching() { + } + + /** + * Build a regex {@link Pattern} that treats every run of whitespace in the + * filter as a wildcard boundary. "text white" becomes {@code .*(text).*(white).*}. + */ + public static Pattern whitespacesPattern(String filter) { + String sFilter = filter.replaceFirst(WS_WILD_START, EMPTY_STR).replaceFirst(WS_WILD_END, EMPTY_STR) + .replaceAll(PAR_START, ONE_CHAR).replaceAll(PAR_END, ONE_CHAR); + sFilter = String.format(".*(%s).*", sFilter.replaceAll(ANY_WS, ").*(")); //$NON-NLS-1$ //$NON-NLS-2$ + return safeCompile(sFilter); + } + + /** + * Build a regex {@link Pattern} that honours {@code *} and {@code ?} wildcards + * in the filter. Consecutive {@code *} are squashed; runs of {@code ?} become a + * bounded length match. + */ + public static Pattern wildcardsPattern(String filter) { + filter = filter.replaceAll("\\*+", "*"); //$NON-NLS-1$ //$NON-NLS-2$ + String sFilter = filter.replaceFirst(WS_WILD_START, EMPTY_STR).replaceFirst(WS_WILD_END, EMPTY_STR) + .replaceAll(PAR_START, ONE_CHAR).replaceAll(PAR_END, ONE_CHAR); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < sFilter.length(); i++) { + char c = sFilter.charAt(i); + if (c == '*') { + sb.append(").").append(c).append("("); //$NON-NLS-1$ //$NON-NLS-2$ + } else if (c == '?') { + int n = 1; + for (; (i + 1) < sFilter.length(); i++) { + if (sFilter.charAt(i + 1) != '?') { + break; + } + n++; + } + sb.append(").").append(n == 1 ? '?' : String.format("{0,%d}", n)).append("("); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + } else { + sb.append(c); + } + } + sFilter = String.format(".*(%s).*", sb.toString()); //$NON-NLS-1$ + sFilter = sFilter.replace("()", EMPTY_STR); //$NON-NLS-1$ + return safeCompile(sFilter); + } + + /** + * Compile a regex or fall back to a pattern that will not match anything + * normal. Never throws. + */ + public static Pattern safeCompile(String regex) { + try { + return Pattern.compile(regex, Pattern.CASE_INSENSITIVE); + } catch (Exception e) { + return Pattern.compile("\\a"); //$NON-NLS-1$ + } + } + + /** + * Quality for the substring-match branch of {@link QuickAccessMatcher#match}. + * {@code filter} must already be lower-cased by the caller (the matcher does + * this once per call). + * + * @return one of {@link QuickAccessEntry#MATCH_PERFECT}, + * {@link QuickAccessEntry#MATCH_EXCELLENT}, + * {@link QuickAccessEntry#MATCH_GOOD}, + * {@link QuickAccessEntry#MATCH_PARTIAL}, or {@code -1} if the filter + * is not a substring of {@code matchLabel}. + */ + public static int substringMatchQuality(String matchLabel, String label, String filter) { + String lowerMatch = matchLabel.toLowerCase(); + if (lowerMatch.indexOf(filter) == -1) { + return -1; + } + if (label.toLowerCase().indexOf(filter) == -1) { + return QuickAccessEntry.MATCH_PARTIAL; + } + if (lowerMatch.equals(filter)) { + return QuickAccessEntry.MATCH_PERFECT; + } + if (lowerMatch.startsWith(filter)) { + return QuickAccessEntry.MATCH_EXCELLENT; + } + return QuickAccessEntry.MATCH_GOOD; + } +} diff --git a/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/quickaccess/QuickAccessMatchingTest.java b/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/quickaccess/QuickAccessMatchingTest.java new file mode 100644 index 00000000000..aec387e3318 --- /dev/null +++ b/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/quickaccess/QuickAccessMatchingTest.java @@ -0,0 +1,112 @@ +/******************************************************************************* + * Copyright (c) 2026 Vogella GmbH and others. + * + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ + +package org.eclipse.ui.tests.quickaccess; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.regex.Pattern; + +import org.eclipse.ui.internal.quickaccess.QuickAccessEntry; +import org.eclipse.ui.internal.quickaccess.QuickAccessMatching; +import org.junit.jupiter.api.Test; + +/** + * Pure unit tests for the matching helpers extracted from + * {@link org.eclipse.ui.internal.quickaccess.QuickAccessMatcher}. These run + * without a workbench harness, so they are fast and not flake-prone. + */ +public class QuickAccessMatchingTest { + + @Test + public void substringQualityExactMatchIsPerfect() { + assertEquals(QuickAccessEntry.MATCH_PERFECT, + QuickAccessMatching.substringMatchQuality("Rename", "Rename", "rename")); + } + + @Test + public void substringQualityPrefixIsExcellent() { + assertEquals(QuickAccessEntry.MATCH_EXCELLENT, + QuickAccessMatching.substringMatchQuality("Rename Resource", "Rename Resource", "rename")); + } + + @Test + public void substringQualityMiddleIsGood() { + assertEquals(QuickAccessEntry.MATCH_GOOD, + QuickAccessMatching.substringMatchQuality("Find and Replace", "Find and Replace", "replace")); + } + + @Test + public void substringQualityPartialWhenOnlyMatchLabelHits() { + // filter hits the match label but not the visible label -> partial + assertEquals(QuickAccessEntry.MATCH_PARTIAL, + QuickAccessMatching.substringMatchQuality("Rename Resource (keyword)", "Rename Resource", "keyword")); + } + + @Test + public void substringQualityReturnsMinusOneOnMiss() { + assertEquals(-1, QuickAccessMatching.substringMatchQuality("Rename", "Rename", "xyzzy")); + } + + @Test + public void whitespacesPatternSplitsOnWhitespace() { + Pattern p = QuickAccessMatching.whitespacesPattern("text white"); + assertTrue(p.matcher("Text Editors: whitespace options").matches()); + assertFalse(p.matcher("Unrelated entry").matches()); + } + + @Test + public void whitespacesPatternIsCaseInsensitive() { + Pattern p = QuickAccessMatching.whitespacesPattern("rename"); + assertTrue(p.matcher("RENAME RESOURCE").matches()); + } + + @Test + public void wildcardsPatternHandlesStar() { + Pattern p = QuickAccessMatching.wildcardsPattern("re*ce"); + assertTrue(p.matcher("Rename Resource").matches()); + assertFalse(p.matcher("Delete").matches()); + } + + @Test + public void wildcardsPatternHandlesSingleQuestionMark() { + Pattern p = QuickAccessMatching.wildcardsPattern("te?t"); + assertTrue(p.matcher("test").matches()); + assertTrue(p.matcher("text").matches()); + } + + @Test + public void wildcardsPatternSquashesConsecutiveStars() { + Pattern a = QuickAccessMatching.wildcardsPattern("re***ce"); + Pattern b = QuickAccessMatching.wildcardsPattern("re*ce"); + // both should treat the input the same way + assertEquals(b.matcher("Rename Resource").matches(), a.matcher("Rename Resource").matches()); + assertTrue(a.matcher("Rename Resource").matches()); + } + + @Test + public void safeCompileReturnsNonMatchingPatternForInvalidRegex() { + // "[" is an unterminated character class -> PatternSyntaxException + Pattern p = QuickAccessMatching.safeCompile("["); + assertNotNull(p); + assertFalse(p.matcher("any text").matches()); + assertFalse(p.matcher("").matches()); + } + + @Test + public void safeCompileCompilesValidRegex() { + Pattern p = QuickAccessMatching.safeCompile("foo.*"); + assertTrue(p.matcher("foobar").matches()); + } +} diff --git a/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/quickaccess/QuickAccessTestSuite.java b/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/quickaccess/QuickAccessTestSuite.java index caade7813c2..899be8622ea 100644 --- a/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/quickaccess/QuickAccessTestSuite.java +++ b/tests/org.eclipse.ui.tests/Eclipse UI Tests/org/eclipse/ui/tests/quickaccess/QuickAccessTestSuite.java @@ -17,7 +17,7 @@ import org.junit.platform.suite.api.Suite; @Suite -@SelectClasses({ CamelUtilTest.class, QuickAccessDialogTest.class, ContentMatchesTest.class, - QuickAccessProvidersTest.class }) +@SelectClasses({ CamelUtilTest.class, QuickAccessMatchingTest.class, QuickAccessDialogTest.class, + ContentMatchesTest.class, QuickAccessProvidersTest.class }) public class QuickAccessTestSuite { }