From 9ad52f13a4c77e749fd4c3d8d21275660bb58c98 Mon Sep 17 00:00:00 2001 From: Mikhail Golubev Date: Wed, 14 Oct 2020 23:28:16 +0300 Subject: [PATCH] PY-44026 Always match the first component of a qualified name in suggestions For instance, "numpy.foo" should not match "pkg.test_numpy.foo". GitOrigin-RevId: b3f3dfb17b0705eb3f93368eb281ba662ea92e3c --- ...vedModuleAttributeCompletionContributor.kt | 4 +- .../PyQualifiedNameCompletionMatcher.java | 81 +++++++++---------- .../bar.py | 2 + .../baz/__init__.py | 0 .../baz/quux.py | 2 + .../foo/__init__.py | 0 .../foo/bar.py | 2 + .../main.py | 1 + ...otImportedQualifiedNameCompletionTest.java | 42 ++++++++++ 9 files changed, 91 insertions(+), 43 deletions(-) create mode 100644 python/testData/completion/notImportedQualifiedName/QualifiedNamesAlwaysMatchedByFirstComponentOfQualifier/bar.py create mode 100644 python/testData/completion/notImportedQualifiedName/QualifiedNamesAlwaysMatchedByFirstComponentOfQualifier/baz/__init__.py create mode 100644 python/testData/completion/notImportedQualifiedName/QualifiedNamesAlwaysMatchedByFirstComponentOfQualifier/baz/quux.py create mode 100644 python/testData/completion/notImportedQualifiedName/QualifiedNamesAlwaysMatchedByFirstComponentOfQualifier/foo/__init__.py create mode 100644 python/testData/completion/notImportedQualifiedName/QualifiedNamesAlwaysMatchedByFirstComponentOfQualifier/foo/bar.py create mode 100644 python/testData/completion/notImportedQualifiedName/QualifiedNamesAlwaysMatchedByFirstComponentOfQualifier/main.py create mode 100644 python/testSrc/com/jetbrains/python/PyNotImportedQualifiedNameCompletionTest.java diff --git a/python/python-psi-impl/src/com/jetbrains/python/codeInsight/completion/PyUnresolvedModuleAttributeCompletionContributor.kt b/python/python-psi-impl/src/com/jetbrains/python/codeInsight/completion/PyUnresolvedModuleAttributeCompletionContributor.kt index 24b85d6303ac..87ef6b99ad9a 100644 --- a/python/python-psi-impl/src/com/jetbrains/python/codeInsight/completion/PyUnresolvedModuleAttributeCompletionContributor.kt +++ b/python/python-psi-impl/src/com/jetbrains/python/codeInsight/completion/PyUnresolvedModuleAttributeCompletionContributor.kt @@ -104,7 +104,7 @@ class PyUnresolvedModuleAttributeCompletionContributor : CompletionContributor() val suggestedQualifiedNames = HashSet() ProgressManager.checkCanceled() - val resultMatchingCompleteReference = result.withPrefixMatcher(QualifiedNameMatcher(qualifierString, attribute)) + val resultMatchingCompleteReference = result.withPrefixMatcher(QualifiedNameMatcher(qualifier.append(attribute))) PyModuleNameIndex.find(qualifier.lastComponent!!, project, true).asSequence() .filter { QualifiedNameFinder.findShortestImportableQName(it) == qualifier } .flatMap { it.iterateNames().asSequence() } @@ -128,7 +128,7 @@ class PyUnresolvedModuleAttributeCompletionContributor : CompletionContributor() } val scope = PySearchUtilBase.excludeSdkTestsScope(project) PyQualifiedNameCompletionMatcher.processMatchingExportedNames( - qualifierString, attribute, parameters.originalFile, scope, + qualifier, attribute, parameters.originalFile, scope, Processor { ProgressManager.checkCanceled() if (suggestedQualifiedNames.add(it.qualifiedName.toString())) { diff --git a/python/python-psi-impl/src/com/jetbrains/python/psi/stubs/PyQualifiedNameCompletionMatcher.java b/python/python-psi-impl/src/com/jetbrains/python/psi/stubs/PyQualifiedNameCompletionMatcher.java index f01a764c3e63..78b570723837 100644 --- a/python/python-psi-impl/src/com/jetbrains/python/psi/stubs/PyQualifiedNameCompletionMatcher.java +++ b/python/python-psi-impl/src/com/jetbrains/python/psi/stubs/PyQualifiedNameCompletionMatcher.java @@ -7,7 +7,6 @@ import com.intellij.openapi.progress.ProcessCanceledException; import com.intellij.openapi.progress.ProgressManager; import com.intellij.openapi.project.Project; import com.intellij.openapi.roots.ProjectFileIndex; -import com.intellij.openapi.util.Couple; import com.intellij.openapi.util.text.StringUtil; import com.intellij.openapi.vfs.VfsUtilCore; import com.intellij.openapi.vfs.VirtualFile; @@ -37,18 +36,17 @@ public class PyQualifiedNameCompletionMatcher { private PyQualifiedNameCompletionMatcher() { } - public static void processMatchingExportedNames(@NotNull String qualifierPattern, + public static void processMatchingExportedNames(@NotNull QualifiedName qualifierPattern, @NotNull String attributePattern, @NotNull PsiFile currentFile, @NotNull GlobalSearchScope scope, @NotNull Processor processor) { - if (attributePattern.isEmpty() || qualifierPattern.isEmpty()) return; - PrefixMatcher attributeMatcher = createNameMatcher(attributePattern); - PrefixMatcher qualifierMatcher = createQualifierMatcher(qualifierPattern); + if (attributePattern.isEmpty() || qualifierPattern.getComponentCount() == 0) return; + QualifiedNameMatcher matcher = new QualifiedNameMatcher(qualifierPattern.append(attributePattern)); StubIndex stubIndex = StubIndex.getInstance(); Project project = Objects.requireNonNull(scope.getProject()); - GlobalSearchScope moduleMatchingScope = new ModuleQualifiedNameMatchingScope(scope, qualifierMatcher, project); + GlobalSearchScope moduleMatchingScope = new ModuleQualifiedNameMatchingScope(scope, matcher, project); Set alreadySuggestedAttributes = new HashSet<>(); IndexLookupStats stats = new IndexLookupStats(); try { @@ -56,7 +54,7 @@ public class PyQualifiedNameCompletionMatcher { stubIndex.processAllKeys(PyExportedModuleAttributeIndex.KEY, attributeName -> { ProgressManager.checkCanceled(); stats.scannedKeys++; - if (!attributeMatcher.isStartMatch(attributeName)) return true; + if (!matcher.attributeMatches(attributeName)) return true; stats.matchingKeys++; return stubIndex.processElements(PyExportedModuleAttributeIndex.KEY, attributeName, project, moduleMatchingScope, idFilter, PyElement.class, element -> { @@ -66,7 +64,7 @@ public class PyQualifiedNameCompletionMatcher { assert moduleQualifiedName != null : vFile; QualifiedName canonicalImportPath = findCanonicalImportPath(element, moduleQualifiedName, currentFile); QualifiedName importPath; - if (canonicalImportPath != null && qualifierMatcher.prefixMatches(canonicalImportPath.toString())) { + if (canonicalImportPath != null && matcher.qualifierMatches(canonicalImportPath)) { importPath = canonicalImportPath; } else { @@ -108,16 +106,6 @@ public class PyQualifiedNameCompletionMatcher { } } - @NotNull - private static CamelHumpMatcher createQualifierMatcher(@NotNull String pattern) { - return new CamelHumpMatcher(pattern, false); - } - - @NotNull - private static PrefixMatcher createNameMatcher(@NotNull String pattern) { - return new CamelHumpMatcher(pattern, false); - } - @Nullable private static QualifiedName findCanonicalImportPath(@NotNull PyElement element, @NotNull QualifiedName moduleQualifiedName, @@ -153,43 +141,54 @@ public class PyQualifiedNameCompletionMatcher { } public static final class QualifiedNameMatcher extends PrefixMatcher { - final PrefixMatcher myLastNameMatcher; - final PrefixMatcher myQualifierMatcher; + private final PrefixMatcher myQualifierFirstComponentMatcher; + private final PrefixMatcher myQualifierRemainderMatcher; + private final PrefixMatcher myLastComponentMatcher; - public QualifiedNameMatcher(@NotNull String qualifierPattern, @NotNull String namePattern) { - super(qualifierPattern + "." + namePattern); - myLastNameMatcher = createNameMatcher(namePattern); - myQualifierMatcher = createQualifierMatcher(qualifierPattern); + public QualifiedNameMatcher(@NotNull QualifiedName qualifiedName) { + super(qualifiedName.toString()); + if (qualifiedName.getComponentCount() < 2) { + throw new IllegalArgumentException("Qualified name should have at least two components, but was '" + qualifiedName + "'"); + } + myLastComponentMatcher = new CamelHumpMatcher(qualifiedName.getLastComponent(), false); + QualifiedName qualifier = qualifiedName.removeLastComponent(); + myQualifierFirstComponentMatcher = new CamelHumpMatcher(qualifier.getFirstComponent(), false); + myQualifierRemainderMatcher = new CamelHumpMatcher(qualifier.removeHead(1).toString(), false); } @Override - public boolean prefixMatches(@NotNull String qualifiedName) { - Couple qualifierAndName = splitByLastDot(qualifiedName); - return myQualifierMatcher.prefixMatches(qualifierAndName.getFirst()) && myLastNameMatcher.prefixMatches(qualifierAndName.getSecond()); + public boolean prefixMatches(@NotNull String name) { + QualifiedName qualifiedName = QualifiedName.fromDottedString(name); + if (qualifiedName.getComponentCount() == 0) return false; + if (!attributeMatches(qualifiedName.getLastComponent())) return false; + if (!qualifierMatches(qualifiedName.removeLastComponent())) return false; + return true; + } + + private boolean attributeMatches(@Nullable String attribute) { + return myLastComponentMatcher.isStartMatch(attribute); + } + + private boolean qualifierMatches(@NotNull QualifiedName qualifier) { + String firstComponent = Objects.requireNonNullElse(qualifier.getFirstComponent(), ""); + if (!myQualifierFirstComponentMatcher.prefixMatches(firstComponent)) return false; + String remainder = qualifier.getComponentCount() == 0 ? "" : qualifier.removeHead(1).toString(); + if (!myQualifierRemainderMatcher.prefixMatches(remainder)) return false; + return true; } @Override public @NotNull PrefixMatcher cloneWithPrefix(@NotNull String prefix) { - Couple qualifierAndName = splitByLastDot(prefix); - return new QualifiedNameMatcher(qualifierAndName.getFirst(), qualifierAndName.getSecond()); - } - - @NotNull - private static Couple splitByLastDot(@NotNull String qualifiedName) { - int lastDotIndex = qualifiedName.lastIndexOf("."); - if (lastDotIndex < 0) { - return Couple.of("", qualifiedName); - } - return Couple.of(qualifiedName.substring(0, lastDotIndex), qualifiedName.substring(lastDotIndex + 1)); + return new QualifiedNameMatcher(QualifiedName.fromDottedString(prefix)); } } private static class ModuleQualifiedNameMatchingScope extends DelegatingGlobalSearchScope { - private final PrefixMatcher myQualifiedNameMatcher; + private final QualifiedNameMatcher myQualifiedNameMatcher; private final Project myProject; ModuleQualifiedNameMatchingScope(@NotNull GlobalSearchScope baseScope, - @NotNull PrefixMatcher qualifiedNameMatcher, + @NotNull QualifiedNameMatcher qualifiedNameMatcher, @NotNull Project project) { super(baseScope); myQualifiedNameMatcher = qualifiedNameMatcher; @@ -201,7 +200,7 @@ public class PyQualifiedNameCompletionMatcher { if (!super.contains(file)) return false; QualifiedName qualifiedName = restoreModuleQualifiedName(file, myProject); if (qualifiedName == null) return false; - return myQualifiedNameMatcher.prefixMatches(qualifiedName.toString()); + return myQualifiedNameMatcher.qualifierMatches(qualifiedName); } @Nullable diff --git a/python/testData/completion/notImportedQualifiedName/QualifiedNamesAlwaysMatchedByFirstComponentOfQualifier/bar.py b/python/testData/completion/notImportedQualifiedName/QualifiedNamesAlwaysMatchedByFirstComponentOfQualifier/bar.py new file mode 100644 index 000000000000..b9bfa6f12339 --- /dev/null +++ b/python/testData/completion/notImportedQualifiedName/QualifiedNamesAlwaysMatchedByFirstComponentOfQualifier/bar.py @@ -0,0 +1,2 @@ +def func(): + pass diff --git a/python/testData/completion/notImportedQualifiedName/QualifiedNamesAlwaysMatchedByFirstComponentOfQualifier/baz/__init__.py b/python/testData/completion/notImportedQualifiedName/QualifiedNamesAlwaysMatchedByFirstComponentOfQualifier/baz/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/testData/completion/notImportedQualifiedName/QualifiedNamesAlwaysMatchedByFirstComponentOfQualifier/baz/quux.py b/python/testData/completion/notImportedQualifiedName/QualifiedNamesAlwaysMatchedByFirstComponentOfQualifier/baz/quux.py new file mode 100644 index 000000000000..b9bfa6f12339 --- /dev/null +++ b/python/testData/completion/notImportedQualifiedName/QualifiedNamesAlwaysMatchedByFirstComponentOfQualifier/baz/quux.py @@ -0,0 +1,2 @@ +def func(): + pass diff --git a/python/testData/completion/notImportedQualifiedName/QualifiedNamesAlwaysMatchedByFirstComponentOfQualifier/foo/__init__.py b/python/testData/completion/notImportedQualifiedName/QualifiedNamesAlwaysMatchedByFirstComponentOfQualifier/foo/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/testData/completion/notImportedQualifiedName/QualifiedNamesAlwaysMatchedByFirstComponentOfQualifier/foo/bar.py b/python/testData/completion/notImportedQualifiedName/QualifiedNamesAlwaysMatchedByFirstComponentOfQualifier/foo/bar.py new file mode 100644 index 000000000000..b9bfa6f12339 --- /dev/null +++ b/python/testData/completion/notImportedQualifiedName/QualifiedNamesAlwaysMatchedByFirstComponentOfQualifier/foo/bar.py @@ -0,0 +1,2 @@ +def func(): + pass diff --git a/python/testData/completion/notImportedQualifiedName/QualifiedNamesAlwaysMatchedByFirstComponentOfQualifier/main.py b/python/testData/completion/notImportedQualifiedName/QualifiedNamesAlwaysMatchedByFirstComponentOfQualifier/main.py new file mode 100644 index 000000000000..8dfe8b9c456b --- /dev/null +++ b/python/testData/completion/notImportedQualifiedName/QualifiedNamesAlwaysMatchedByFirstComponentOfQualifier/main.py @@ -0,0 +1 @@ +ba.fu \ No newline at end of file diff --git a/python/testSrc/com/jetbrains/python/PyNotImportedQualifiedNameCompletionTest.java b/python/testSrc/com/jetbrains/python/PyNotImportedQualifiedNameCompletionTest.java new file mode 100644 index 000000000000..4603ba4cf0d8 --- /dev/null +++ b/python/testSrc/com/jetbrains/python/PyNotImportedQualifiedNameCompletionTest.java @@ -0,0 +1,42 @@ +// Copyright 2000-2020 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. +package com.jetbrains.python; + +import com.intellij.psi.util.QualifiedName; +import com.jetbrains.python.fixtures.PyTestCase; +import com.jetbrains.python.psi.stubs.PyQualifiedNameCompletionMatcher.QualifiedNameMatcher; + +import java.util.List; + +public class PyNotImportedQualifiedNameCompletionTest extends PyTestCase { + public void testQualifiedNamesAlwaysMatchedByFirstComponentOfQualifier() { + myFixture.copyDirectoryToProject(getTestName(false), ""); + myFixture.configureByFile("main.py"); + myFixture.completeBasic(); + List variants = myFixture.getLookupElementStrings(); + assertNotNull(variants); + assertDoesntContain(variants, "foo.bar.func"); + assertContainsElements(variants, "bar.func", "baz.quux.func"); + } + + public void testQualifiedNameMatcherTest() { + QualifiedNameMatcher matcher = new QualifiedNameMatcher(QualifiedName.fromDottedString("foo.bar.baz")); + assertTrue(matcher.prefixMatches("foo.bar.baz")); + assertTrue(matcher.prefixMatches("foo.bar.baz")); + assertTrue(matcher.prefixMatches("fooExtra.bar.baz")); + assertTrue(matcher.prefixMatches("foo.barExtra.baz")); + assertTrue(matcher.prefixMatches("foo.bar.bazExtra")); + assertFalse(matcher.prefixMatches("")); + assertFalse(matcher.prefixMatches("baz")); + assertFalse(matcher.prefixMatches("foo.bar")); + assertFalse(matcher.prefixMatches("bar.baz")); + assertTrue(matcher.prefixMatches("foo.xxx.bar.baz")); + assertTrue(matcher.prefixMatches("foo.bar.xxx.baz")); + assertFalse(matcher.prefixMatches("foo.bar.baz.xxx")); + assertFalse(matcher.prefixMatches("xxx.foo.bar.baz")); + } + + @Override + protected String getTestDataPath() { + return super.getTestDataPath() + "/completion/notImportedQualifiedName/"; + } +}