From df067cce4e6675196ffc94ef3fb6d0b69da85fc0 Mon Sep 17 00:00:00 2001 From: peter Date: Mon, 11 Sep 2017 15:08:24 +0200 Subject: [PATCH] IDEA-112841 Navigate to file action should support fuzzier path search terms --- .../java/navigation/ChooseByNameTest.groovy | 16 +- .../ide/actions/GotoFileItemProvider.java | 145 ++++++++++++++++-- .../DefaultChooseByNameItemProvider.java | 13 +- .../ide/util/gotoByName/MatchResult.java | 11 +- 4 files changed, 161 insertions(+), 24 deletions(-) diff --git a/java/java-tests/testSrc/com/intellij/java/navigation/ChooseByNameTest.groovy b/java/java-tests/testSrc/com/intellij/java/navigation/ChooseByNameTest.groovy index 4f291283bf19..2ecd4a536aa0 100644 --- a/java/java-tests/testSrc/com/intellij/java/navigation/ChooseByNameTest.groovy +++ b/java/java-tests/testSrc/com/intellij/java/navigation/ChooseByNameTest.groovy @@ -16,7 +16,6 @@ package com.intellij.java.navigation import com.intellij.codeInsight.JavaProjectCodeInsightSettings -import com.intellij.ide.actions.GotoFileItemProvider import com.intellij.ide.util.gotoByName.* import com.intellij.lang.java.JavaLanguage import com.intellij.openapi.Disposable @@ -186,8 +185,7 @@ class Intf { void "test accept file paths starting with a dot"() { def file = myFixture.addFileToProject("foo/index.html", "foo") - def model = new GotoFileModel(project) - def popup = ChooseByNamePopup.createPopup(project, model, new GotoFileItemProvider(project, null, model)) + def popup = createPopup(new GotoFileModel(project)) assert calcPopupElements(popup, "./foo/in") == [file] } @@ -380,6 +378,18 @@ class Intf { assert calcPopupElements(popup, "List", false) == [foo, bar] } + void "test file path matching without slashes"() { + def fooBarFile = myFixture.addFileToProject("foo/bar/index_fooBar.html", "") + def fbFile = myFixture.addFileToProject("fb/index_fb.html", "") + def fbSomeFile = myFixture.addFileToProject("fb/some/index_fbSome.html", "") + def someFbFile = myFixture.addFileToProject("some/fb/index_someFb.html", "") + def model = new GotoFileModel(project) + def popup = createPopup(model) + assert calcPopupElements(popup, "barindex") == [fooBarFile] + assert calcPopupElements(popup, "fooindex") == [fooBarFile] + assert calcPopupElements(popup, "fbindex") == [fbFile, someFbFile, fbSomeFile, fooBarFile] + } + private List getPopupElements(ChooseByNameModel model, String text, boolean checkboxState = false) { return calcPopupElements(createPopup(model), text, checkboxState) } diff --git a/platform/lang-impl/src/com/intellij/ide/actions/GotoFileItemProvider.java b/platform/lang-impl/src/com/intellij/ide/actions/GotoFileItemProvider.java index 7fbb676c2f45..8d7ef356d4a8 100644 --- a/platform/lang-impl/src/com/intellij/ide/actions/GotoFileItemProvider.java +++ b/platform/lang-impl/src/com/intellij/ide/actions/GotoFileItemProvider.java @@ -15,23 +15,29 @@ */ package com.intellij.ide.actions; -import com.intellij.ide.util.gotoByName.ChooseByNameBase; -import com.intellij.ide.util.gotoByName.ChooseByNamePopup; -import com.intellij.ide.util.gotoByName.DefaultChooseByNameItemProvider; -import com.intellij.ide.util.gotoByName.GotoFileModel; +import com.intellij.ide.util.gotoByName.*; import com.intellij.openapi.progress.ProgressIndicator; import com.intellij.openapi.project.Project; import com.intellij.openapi.roots.ProjectFileIndex; +import com.intellij.openapi.util.TextRange; import com.intellij.openapi.util.io.FileUtil; +import com.intellij.openapi.util.text.StringUtil; import com.intellij.openapi.vfs.LocalFileSystem; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.psi.PsiElement; import com.intellij.psi.PsiFileSystemItem; import com.intellij.psi.PsiManager; +import com.intellij.psi.codeStyle.MinusculeMatcher; +import com.intellij.psi.codeStyle.NameUtil; import com.intellij.util.Processor; +import com.intellij.util.containers.ContainerUtil; +import com.intellij.util.containers.FList; +import com.intellij.util.containers.HashSet; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.util.*; + /** * @author peter */ @@ -51,26 +57,141 @@ public class GotoFileItemProvider extends DefaultChooseByNameItemProvider { boolean everywhere, @NotNull ProgressIndicator indicator, @NotNull Processor consumer) { + PsiFileSystemItem absolute = getFileByAbsolutePath(pattern); + if (absolute != null && !consumer.process(absolute)) { + return true; + } + + + if (pattern.startsWith("./") || pattern.startsWith(".\\")) { + pattern = pattern.substring(1); + } + + String sanitized = removeSlashes(base.transformPattern(pattern)); + List> nameMatches = getFileNameCandidates(base, everywhere, sanitized, !pattern.startsWith("*")); + + MinusculeMatcher fullMatcher = NameUtil.buildMatcher("*" + sanitized, NameUtil.MatchingCaseSensitivity.NONE); + PathProximityComparator pathProximityComparator = getPathProximityComparator(); + + for (List group : nameMatches) { + if (!ContainerUtil.process(getFilesMatchingPath(pattern, everywhere, fullMatcher, pathProximityComparator, group), consumer)) { + return false; + } + } + + return true; + } + + @NotNull + private static String removeSlashes(String s) { + if (s.startsWith("/") || s.startsWith("\\")) return removeSlashes(s.substring(1)); + if (s.endsWith("/") || s.endsWith("\\")) return removeSlashes(s.substring(0, s.length() - 1)); + return s; + } + + @Nullable + private PsiFileSystemItem getFileByAbsolutePath(@NotNull String pattern) { if (pattern.contains("/") || pattern.contains("\\")) { String path = FileUtil.toSystemIndependentName(ChooseByNamePopup.getTransformedPattern(pattern, myModel)); VirtualFile vFile = LocalFileSystem.getInstance().findFileByPathIfCached(path); if (vFile != null) { ProjectFileIndex index = ProjectFileIndex.SERVICE.getInstance(myProject); if (index.isInContent(vFile) || index.isInLibraryClasses(vFile) || index.isInLibrarySource(vFile)) { - PsiFileSystemItem fileOrDir = vFile.isDirectory() ? - PsiManager.getInstance(myProject).findDirectory(vFile) : - PsiManager.getInstance(myProject).findFile(vFile); - if (fileOrDir != null && !consumer.process(fileOrDir)) { - return false; + return vFile.isDirectory() ? PsiManager.getInstance(myProject).findDirectory(vFile) : PsiManager.getInstance(myProject).findFile(vFile); + } + } + } + return null; + } + + @NotNull + private List getFilesMatchingPath(@NotNull String pattern, + boolean everywhere, + MinusculeMatcher fullMatcher, + PathProximityComparator pathProximityComparator, List fileNames) { + List group = new ArrayList<>(); + Map qualifierMatchingDegrees = new HashMap<>(); + Map dirCloseness = new HashMap<>(); + Map nesting = new HashMap<>(); + for (String fileName : fileNames) { + for (Object o : myModel.getElementsByName(fileName, everywhere, pattern)) { + String fullName = myModel.getFullName(o); + if (o instanceof PsiFileSystemItem && fullName != null) { + FList fragments = fullMatcher.matchingFragments(fullName); + if (fragments != null) { + group.add((PsiFileSystemItem)o); + + qualifierMatchingDegrees.put((PsiFileSystemItem)o, -fullMatcher.matchingDegree(fullName, false, fragments)); + + String matchingArea = fullName.substring(fragments.getHead().getStartOffset(), fragments.get(fragments.size() - 1).getEndOffset()); + dirCloseness.put((PsiFileSystemItem)o, StringUtil.countChars(matchingArea, '/')); + + nesting.put((PsiFileSystemItem)o, StringUtil.countChars(fullName, '/')); } } } } - if (pattern.startsWith("./") || pattern.startsWith(".\\")) { - pattern = pattern.substring(1); + if (group.size() > 1) { + Collections.sort(group, Comparator.comparing(nesting::get).thenComparing(dirCloseness::get).thenComparing(qualifierMatchingDegrees::get).thenComparing(pathProximityComparator).thenComparing(myModel::getFullName)); + } + return group; + } + + @NotNull + private List> getFileNameCandidates(@NotNull ChooseByNameBase base, + boolean everywhere, + String sanitized, boolean preferStartMatches) { + String[] names = myModel.getNames(everywhere); + + int start = Math.max(sanitized.lastIndexOf('/'), sanitized.lastIndexOf('\\')) + 1; + + Set checkedNames = new HashSet<>(); + List> groups = new ArrayList<>(); + for (int i = start; i < sanitized.length() - 1; i++) { + List nameMatches = new ArrayList<>(); + String namePattern = sanitized.substring(i); + MinusculeMatcher matcher = NameUtil.buildMatcher(namePattern, NameUtil.MatchingCaseSensitivity.NONE); + for (String name : names) { + if (!checkedNames.contains(name)) { + MatchResult result = matches(base, namePattern, matcher, name); + if (result != null) { + checkedNames.add(name); + nameMatches.add(result); + } + } + } + groups.addAll(groupByMatchingDegree(nameMatches, namePattern, preferStartMatches)); } - return super.filterElements(base, pattern, everywhere, indicator, consumer); + return groups; + } + + private static List> groupByMatchingDegree(List nameMatches, + String namePattern, boolean preferStartMatches) { + if (nameMatches.isEmpty()) return Collections.emptyList(); + + List> groups = new ArrayList<>(); + + Comparator comparator = (mr1, mr2) -> { + boolean exactPrefix1 = namePattern.equalsIgnoreCase(mr1.elementName); + boolean exactPrefix2 = namePattern.equalsIgnoreCase(mr2.elementName); + if (exactPrefix1 != exactPrefix2) return exactPrefix1 ? -1 : 1; + return mr1.compareDegrees(mr2, preferStartMatches); + }; + Collections.sort(nameMatches, comparator); + + List group = ContainerUtil.newArrayList(nameMatches.get(0).elementName); + for (int j = 1; j < nameMatches.size(); j++) { + MatchResult current = nameMatches.get(j); + if (comparator.compare(nameMatches.get(j - 1), current) == 0) { + group.add(current.elementName); + } else { + groups.add(group); + group = ContainerUtil.newArrayList(current.elementName); + } + } + groups.add(group); + return groups; } } diff --git a/platform/lang-impl/src/com/intellij/ide/util/gotoByName/DefaultChooseByNameItemProvider.java b/platform/lang-impl/src/com/intellij/ide/util/gotoByName/DefaultChooseByNameItemProvider.java index 4df30c5b67aa..d486f81d05ed 100644 --- a/platform/lang-impl/src/com/intellij/ide/util/gotoByName/DefaultChooseByNameItemProvider.java +++ b/platform/lang-impl/src/com/intellij/ide/util/gotoByName/DefaultChooseByNameItemProvider.java @@ -113,9 +113,7 @@ public class DefaultChooseByNameItemProvider implements ChooseByNameItemProvider Comparator weightComparator = new Comparator() { @SuppressWarnings("unchecked") - Comparator modelComparator = model instanceof Comparator - ? (Comparator)model - : new PathProximityComparator(myContext == null ? null :myContext.getElement()); + Comparator modelComparator = model instanceof Comparator ? (Comparator)model : getPathProximityComparator(); @Override public int compare(Object o1, Object o2) { @@ -173,6 +171,11 @@ public class DefaultChooseByNameItemProvider implements ChooseByNameItemProvider return ContainerUtil.process(qualifierMiddleMatched, consumer); } + @NotNull + protected PathProximityComparator getPathProximityComparator() { + return new PathProximityComparator(myContext == null ? null : myContext.getElement()); + } + private static void sortNamesList(@NotNull String namePattern, @NotNull List namesList, boolean preferStartMatches) { Collections.sort(namesList, (mr1, mr2) -> { boolean exactPrefix1 = namePattern.equalsIgnoreCase(mr1.elementName); @@ -345,7 +348,7 @@ public class DefaultChooseByNameItemProvider implements ChooseByNameItemProvider } @Nullable - private static MatchResult matches(@NotNull ChooseByNameBase base, + protected static MatchResult matches(@NotNull ChooseByNameBase base, @NotNull String pattern, @NotNull MinusculeMatcher matcher, @Nullable String name) { @@ -370,7 +373,7 @@ public class DefaultChooseByNameItemProvider implements ChooseByNameItemProvider return NameUtil.buildMatcher(pattern, caseSensitivity); } - private static class PathProximityComparator implements Comparator { + protected static class PathProximityComparator implements Comparator { @NotNull private final PsiProximityComparator myProximityComparator; private PathProximityComparator(@Nullable final PsiElement context) { diff --git a/platform/lang-impl/src/com/intellij/ide/util/gotoByName/MatchResult.java b/platform/lang-impl/src/com/intellij/ide/util/gotoByName/MatchResult.java index 6be66c387c47..edc34850623f 100644 --- a/platform/lang-impl/src/com/intellij/ide/util/gotoByName/MatchResult.java +++ b/platform/lang-impl/src/com/intellij/ide/util/gotoByName/MatchResult.java @@ -17,7 +17,7 @@ package com.intellij.ide.util.gotoByName; import org.jetbrains.annotations.NotNull; -class MatchResult { +public class MatchResult { public final String elementName; final int matchingDegree; final boolean startMatch; @@ -28,7 +28,7 @@ class MatchResult { this.startMatch = startMatch; } - int compareWith(@NotNull MatchResult that, boolean preferStartMatches) { + public int compareDegrees(@NotNull MatchResult that, boolean preferStartMatches) { boolean start1 = startMatch; boolean start2 = that.startMatch; int startMatchResult = start1 == start2 ? 0 : start1 ? -1 : 1; @@ -39,8 +39,11 @@ class MatchResult { if (degree2 < degree1) return -1; if (degree2 > degree1) return 1; - if (startMatchResult != 0) return -startMatchResult; + return -startMatchResult; + } - return elementName.compareToIgnoreCase(that.elementName); + int compareWith(@NotNull MatchResult that, boolean preferStartMatches) { + int result = compareDegrees(that, preferStartMatches); + return result != 0 ? result : elementName.compareToIgnoreCase(that.elementName); } }