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