diff --git a/python/python-psi-impl/test/com/jetbrains/python/completion/PythonCompletionTest.java b/python/python-psi-impl/test/com/jetbrains/python/completion/PythonCompletionTest.java index ff573c158b18..c1fa933ef420 100644 --- a/python/python-psi-impl/test/com/jetbrains/python/completion/PythonCompletionTest.java +++ b/python/python-psi-impl/test/com/jetbrains/python/completion/PythonCompletionTest.java @@ -1440,6 +1440,31 @@ public class PythonCompletionTest extends PyTestCase { assertContainsElements(suggested, "'k1'", "'k2'"); } + // PY-33254 + public void testMultipartStringPath() { + doMultiFileTest(); + } + + // PY-33254 + public void testRbStringPath() { + doMultiFileTest(); + } + + // PY-33254 + public void testKeywordArgumentPatternStringPath() { + doMultiFileTest(); + } + + // PY-33254 + public void testBuiltinOpenStringPath() { + doMultiFileTest(); + } + + // PY-33254 + public void testPandasReadCsvStringPath() { + doMultiFileTest(); + } + // PY-8302 public void testUndeclaredFunction() { myFixture.configureByFile("uninitialized/fun.py"); diff --git a/python/src/META-INF/python-core-common.xml b/python/src/META-INF/python-core-common.xml index 71d0bbbbd6ce..8dda4e0a446b 100644 --- a/python/src/META-INF/python-core-common.xml +++ b/python/src/META-INF/python-core-common.xml @@ -531,6 +531,7 @@ + ("callArgumentMatchingPattern") { + override fun accepts(expr: PyStringLiteralExpression, context: ProcessingContext?): Boolean { + val argList = expr.parent as? PyArgumentList ?: return false + val callExpr = argList.parent as? PyCallExpression ?: return false + val resolveContext = PyResolveContext.noImplicits().withTypeEvalContext(TypeEvalContext.codeInsightFallback(expr.project)) + + // Fail-fast check + val allPossibleParameters = callExpr.multiResolveCalleeFunction(resolveContext) + .flatMap { it.parameterList.parameters.asList() } + .mapNotNull { it.name } + if (!allPossibleParameters.any { matchesPathNamePattern(it) }) return false + + val mappings = callExpr.multiMapArguments(resolveContext) + return mappings.any { mapping -> + mapping.mappedParameters[expr]?.name?.let { matchesPathNamePattern(it) } ?: false + } + } + } + + /** + * Matches string literals used as function keyword arguments where the keyword has a name that has something about files or paths. + */ + object KeywordArgumentMatchingNamePattern : PatternCondition("keywordArgumentMatchingPattern") { + override fun accepts(expr: PyStringLiteralExpression, context: ProcessingContext?): Boolean { + val keywordArgument = expr.parent as? PyKeywordArgument ?: return false + if (keywordArgument.parent?.parent !is PyCallExpression) return false + return keywordArgument.keyword?.let { matchesPathNamePattern(it) } ?: return false + } + } + + /** + * Matches string literals in a Python console that have at least one path separator in them. + */ + object StringWithPathSeparatorInConsole : PatternCondition("stringWithSeparatorInConsolePattern") { + override fun accepts(expr: PyStringLiteralExpression, context: ProcessingContext?): Boolean { + val containingFile = expr.containingFile ?: return false + if (!PythonRuntimeService.getInstance().isInPydevConsole(containingFile)) return false + return File.separator in expr.stringValue + } + } + + /** + * Matches string literals used as function arguments for the hard-coded set of function known to work with files. + * + * It's a last resort where we cannot guess that the string literal is a file path by other means. + */ + object HardCodedCalleeName : PatternCondition("hardCodedCalleeName") { + private data class Pattern(private val fullName: String, val position: Int, val isBuiltin: Boolean = false) { + val qualifiedName: QualifiedName = QualifiedName.fromDottedString(fullName) + } + + private val PATTERNS = listOf( + Pattern("open", 0, isBuiltin = true), + Pattern("pandas.read_csv", 0) + ) + private val SIMPLE_NAMES = PATTERNS.associateBy { it.qualifiedName.lastComponent } + private val QUALIFIED_NAMES = PATTERNS.associateBy { it.qualifiedName } + + override fun accepts(expr: PyStringLiteralExpression, context: ProcessingContext?): Boolean { + val argList = expr.parent as? PyArgumentList ?: return false + val callExpr = argList.parent as? PyCallExpression ?: return false + val callee = callExpr.callee as? PyReferenceExpression ?: return false + + // Fail-fast check + val simplePattern = SIMPLE_NAMES[callee.name] ?: return false + if (simplePattern.isBuiltin) return true + + val pattern = PyResolveUtil.resolveImportedElementQNameLocally(callee).map { QUALIFIED_NAMES[it] }.firstOrNull() ?: return false + return argList.arguments.getOrNull(pattern.position) == expr + } + } + + companion object { + private val FILE_NAME_PATTERNS = linkedSetOf( + "path", + "file", + "filename", + "filepath" + ) + + private fun matchesPathNamePattern(name: String): Boolean { + val nameParts = name.split("_") + return nameParts.any { it.toLowerCase() in FILE_NAME_PATTERNS } + } + } + + private object PySoftFileReferenceProvider : PsiReferenceProvider() { + override fun getReferencesByElement(element: PsiElement, context: ProcessingContext): Array { + val expr = element as? PyStringLiteralExpression ?: return emptyArray() + return PySoftFileReferenceSet(expr, this).allReferences + } + } + + /** + * A soft file reference in Python string literals. + * + * * It's used only for code completion and ignored during code inspections + * * It understands `~` as an alias for the user home path + * * It provides the context for resolving paths in a Python console relative to its initial working directory + */ + private class PySoftFileReferenceSet(element: PyStringLiteralExpression, provider: PsiReferenceProvider) : + PyStringLiteralFileReferenceSet(element.stringValue, element, + ElementManipulators.getValueTextRange(element).startOffset, provider, true, false, null) { + + override fun isSoft(): Boolean = true + + override fun createFileReference(range: TextRange, index: Int, text: String): FileReference? = + super.createFileReference(range, index, expandUserHome(text)) + + override fun isAbsolutePathReference(): Boolean = + super.isAbsolutePathReference() || pathString.startsWith("~") + + override fun computeDefaultContexts(): Collection { + val defaultContexts = super.computeDefaultContexts() + val consoleDir = getConsoleWorkingDirectory() ?: return defaultContexts + return defaultContexts + consoleDir + } + + private fun expandUserHome(text: String): String? = + when (text) { + "~" -> System.getProperty("user.home") + else -> text + } + + private fun getConsoleWorkingDirectory(): PsiDirectory? { + val file = containingFile ?: return null + if (!PythonRuntimeService.getInstance().isInPydevConsole(file)) return null + val project = file.project + val module = ModuleUtilCore.findModuleForFile(file) + val settingsProvider = PyConsoleOptions.getInstance(project).pythonConsoleSettings + val workingDirPath = PydevConsoleRunnerFactory.getWorkingDir(project, module, null, settingsProvider) ?: return null + val workingDir = StandardFileSystems.local().findFileByPath(workingDirPath) ?: return null + return PsiManager.getInstance(project).findDirectory(workingDir) + } + } +} \ No newline at end of file diff --git a/python/testData/completion/builtinOpenStringPath/a.after.py b/python/testData/completion/builtinOpenStringPath/a.after.py new file mode 100644 index 000000000000..e8d65875ccbc --- /dev/null +++ b/python/testData/completion/builtinOpenStringPath/a.after.py @@ -0,0 +1 @@ +open('foobar.txt') diff --git a/python/testData/completion/builtinOpenStringPath/a.py b/python/testData/completion/builtinOpenStringPath/a.py new file mode 100644 index 000000000000..f79adca45f6b --- /dev/null +++ b/python/testData/completion/builtinOpenStringPath/a.py @@ -0,0 +1 @@ +open('foo') diff --git a/python/testData/completion/builtinOpenStringPath/foobar.txt b/python/testData/completion/builtinOpenStringPath/foobar.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/testData/completion/keywordArgumentPatternStringPath/a.after.py b/python/testData/completion/keywordArgumentPatternStringPath/a.after.py new file mode 100644 index 000000000000..e1fc58ce4ef5 --- /dev/null +++ b/python/testData/completion/keywordArgumentPatternStringPath/a.after.py @@ -0,0 +1,5 @@ +def f(*args, **kwargs): + pass + + +f(path_to_database='foobar.txt') diff --git a/python/testData/completion/keywordArgumentPatternStringPath/a.py b/python/testData/completion/keywordArgumentPatternStringPath/a.py new file mode 100644 index 000000000000..ea01cc9bf548 --- /dev/null +++ b/python/testData/completion/keywordArgumentPatternStringPath/a.py @@ -0,0 +1,5 @@ +def f(*args, **kwargs): + pass + + +f(path_to_database='foo') diff --git a/python/testData/completion/keywordArgumentPatternStringPath/foobar.txt b/python/testData/completion/keywordArgumentPatternStringPath/foobar.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/testData/completion/multipartStringPath/a.after.py b/python/testData/completion/multipartStringPath/a.after.py new file mode 100644 index 000000000000..8fa0c72d7441 --- /dev/null +++ b/python/testData/completion/multipartStringPath/a.after.py @@ -0,0 +1,6 @@ +def f(path): + pass + + +f("fo" + "obar/empty.txt") diff --git a/python/testData/completion/multipartStringPath/a.py b/python/testData/completion/multipartStringPath/a.py new file mode 100644 index 000000000000..c20b5df8ab36 --- /dev/null +++ b/python/testData/completion/multipartStringPath/a.py @@ -0,0 +1,6 @@ +def f(path): + pass + + +f("fo" + "obar/") diff --git a/python/testData/completion/multipartStringPath/foobar/empty.txt b/python/testData/completion/multipartStringPath/foobar/empty.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/testData/completion/pandasReadCsvStringPath/a.after.py b/python/testData/completion/pandasReadCsvStringPath/a.after.py new file mode 100644 index 000000000000..a00786650e7c --- /dev/null +++ b/python/testData/completion/pandasReadCsvStringPath/a.after.py @@ -0,0 +1,4 @@ +import pandas as pd + + +pd.read_csv('foobar.txt') diff --git a/python/testData/completion/pandasReadCsvStringPath/a.py b/python/testData/completion/pandasReadCsvStringPath/a.py new file mode 100644 index 000000000000..cf7ebedbd652 --- /dev/null +++ b/python/testData/completion/pandasReadCsvStringPath/a.py @@ -0,0 +1,4 @@ +import pandas as pd + + +pd.read_csv('foo') diff --git a/python/testData/completion/pandasReadCsvStringPath/foobar.txt b/python/testData/completion/pandasReadCsvStringPath/foobar.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/testData/completion/rbStringPath/a.after.py b/python/testData/completion/rbStringPath/a.after.py new file mode 100644 index 000000000000..87a82d22a1fe --- /dev/null +++ b/python/testData/completion/rbStringPath/a.after.py @@ -0,0 +1,5 @@ +def f(storage_path): + pass + + +f(rb"./foobar.txt") diff --git a/python/testData/completion/rbStringPath/a.py b/python/testData/completion/rbStringPath/a.py new file mode 100644 index 000000000000..96edda9afa7e --- /dev/null +++ b/python/testData/completion/rbStringPath/a.py @@ -0,0 +1,5 @@ +def f(storage_path): + pass + + +f(rb"./foo") diff --git a/python/testData/completion/rbStringPath/foobar.txt b/python/testData/completion/rbStringPath/foobar.txt new file mode 100644 index 000000000000..e69de29bb2d1