From 788e8300e4ecd151e4ad049339d859cf1ad34c8e Mon Sep 17 00:00:00 2001 From: Nikita Paniukhin Date: Tue, 2 Dec 2025 21:55:58 +0000 Subject: [PATCH] [python] PY-85601 Add "invalid escape sequence" inspection GitOrigin-RevId: d750f06efc5e42678073de084ecb2976acedd4b9 --- .../PyInvalidEscapeSequenceInspection.html | 22 +++++ .../resources/intellij.python.psi.impl.xml | 1 + .../resources/messages/PyPsiBundle.properties | 8 ++ .../PyInvalidEscapeSequenceInspection.kt | 91 +++++++++++++++++++ .../quickfix/PyConvertToRawStringQuickFix.kt | 28 ++++++ .../quickfix/PyEscapeBackslashQuickFix.kt | 25 +++++ .../basic.py | 9 ++ .../bytes.py | 9 ++ .../convertToRawStringNotAvailable_before.py | 1 + .../convertToRawString_after.py | 1 + .../convertToRawString_before.py | 1 + .../escapeBackslash_after.py | 1 + .../escapeBackslash_before.py | 1 + .../fstrings.py | 44 +++++++++ .../PyInvalidEscapeSequenceInspectionTest.kt | 43 +++++++++ 15 files changed, 285 insertions(+) create mode 100644 python/python-psi-impl/resources/inspectionDescriptions/PyInvalidEscapeSequenceInspection.html create mode 100644 python/python-psi-impl/src/com/jetbrains/python/inspections/PyInvalidEscapeSequenceInspection.kt create mode 100644 python/python-psi-impl/src/com/jetbrains/python/inspections/quickfix/PyConvertToRawStringQuickFix.kt create mode 100644 python/python-psi-impl/src/com/jetbrains/python/inspections/quickfix/PyEscapeBackslashQuickFix.kt create mode 100644 python/testData/inspections/PyInvalidEscapeSequenceInspection/basic.py create mode 100644 python/testData/inspections/PyInvalidEscapeSequenceInspection/bytes.py create mode 100644 python/testData/inspections/PyInvalidEscapeSequenceInspection/convertToRawStringNotAvailable_before.py create mode 100644 python/testData/inspections/PyInvalidEscapeSequenceInspection/convertToRawString_after.py create mode 100644 python/testData/inspections/PyInvalidEscapeSequenceInspection/convertToRawString_before.py create mode 100644 python/testData/inspections/PyInvalidEscapeSequenceInspection/escapeBackslash_after.py create mode 100644 python/testData/inspections/PyInvalidEscapeSequenceInspection/escapeBackslash_before.py create mode 100644 python/testData/inspections/PyInvalidEscapeSequenceInspection/fstrings.py create mode 100644 python/testSrc/com/jetbrains/python/inspections/PyInvalidEscapeSequenceInspectionTest.kt diff --git a/python/python-psi-impl/resources/inspectionDescriptions/PyInvalidEscapeSequenceInspection.html b/python/python-psi-impl/resources/inspectionDescriptions/PyInvalidEscapeSequenceInspection.html new file mode 100644 index 000000000000..636b26326d4f --- /dev/null +++ b/python/python-psi-impl/resources/inspectionDescriptions/PyInvalidEscapeSequenceInspection.html @@ -0,0 +1,22 @@ + + +Reports invalid escape sequences in string and bytes literals. + +

+ Starting from Python 3.6, escape sequences that are not recognized (such as \.) produce a SyntaxWarning. + In future Python versions, they will become a SyntaxError. +

+

+ To fix this, you can either escape the backslash (e.g., \\.) or use a raw string (e.g., r'\.'). +

+

Example:

+

+# Warning: Invalid escape sequence '\.'
+print('\.')
+
+# Correct
+print(r'\.')
+print('\\.')
+
+ + diff --git a/python/python-psi-impl/resources/intellij.python.psi.impl.xml b/python/python-psi-impl/resources/intellij.python.psi.impl.xml index 9a7d8241b640..c4cf06f0c13c 100644 --- a/python/python-psi-impl/resources/intellij.python.psi.impl.xml +++ b/python/python-psi-impl/resources/intellij.python.psi.impl.xml @@ -269,6 +269,7 @@ + Python diff --git a/python/python-psi-impl/resources/messages/PyPsiBundle.properties b/python/python-psi-impl/resources/messages/PyPsiBundle.properties index 124daeb941f2..294879ab951b 100644 --- a/python/python-psi-impl/resources/messages/PyPsiBundle.properties +++ b/python/python-psi-impl/resources/messages/PyPsiBundle.properties @@ -1378,3 +1378,11 @@ QFIX.remove.cast.call=Remove 'cast' call QFIX.add.source.root.notification.text=''{0}'' has been marked as source root QFIX.add.source.root.notification.ok=Ok QFIX.add.source.root.notification.revert=Revert + +# PyInvalidEscapeSequenceInspection +INSP.NAME.invalid.escape.sequence=Invalid escape sequence +INSP.invalid.escape.sequence=Invalid escape sequence ''{0}'' + +# Quick fixes for PyInvalidEscapeSequenceInspection +QFIX.escape.backslash=Escape backslash +QFIX.convert.to.raw.string=Convert to raw string diff --git a/python/python-psi-impl/src/com/jetbrains/python/inspections/PyInvalidEscapeSequenceInspection.kt b/python/python-psi-impl/src/com/jetbrains/python/inspections/PyInvalidEscapeSequenceInspection.kt new file mode 100644 index 000000000000..f0dd82471475 --- /dev/null +++ b/python/python-psi-impl/src/com/jetbrains/python/inspections/PyInvalidEscapeSequenceInspection.kt @@ -0,0 +1,91 @@ +package com.jetbrains.python.inspections + +import com.intellij.codeInspection.LocalInspectionToolSession +import com.intellij.codeInspection.LocalQuickFix +import com.intellij.codeInspection.ProblemHighlightType +import com.intellij.codeInspection.ProblemsHolder +import com.intellij.openapi.util.TextRange +import com.intellij.psi.PsiElementVisitor +import com.jetbrains.python.PyPsiBundle +import com.jetbrains.python.inspections.quickfix.PyConvertToRawStringQuickFix +import com.jetbrains.python.inspections.quickfix.PyEscapeBackslashQuickFix +import com.jetbrains.python.psi.PyFormattedStringElement +import com.jetbrains.python.psi.PyStringElement +import com.jetbrains.python.psi.PyStringLiteralExpression +import com.jetbrains.python.psi.impl.PyStringLiteralDecoder +import com.jetbrains.python.psi.types.TypeEvalContext + +class PyInvalidEscapeSequenceInspection : PyInspection() { + + override fun buildVisitor( + holder: ProblemsHolder, + isOnTheFly: Boolean, + session: LocalInspectionToolSession, + ): PsiElementVisitor = Visitor(holder, PyInspectionVisitor.getContext(session)) + + private class Visitor(holder: ProblemsHolder?, context: TypeEvalContext) : PyInspectionVisitor(holder, context) { + + override fun visitPyStringLiteralExpression(node: PyStringLiteralExpression) = + node.stringElements.forEach { processString(it, node) } + + private fun processString(element: PyStringElement, node: PyStringLiteralExpression) { + if (element.prefix.contains("r", ignoreCase = true)) return + + val content = element.contentRange.substring(element.text) + val offsetInNode = element.textRange.startOffset + element.contentRange.startOffset - node.textRange.startOffset + + val fstringRanges = if (element is PyFormattedStringElement) { + element.literalPartRanges.asSequence().map { it.shiftLeft(element.contentRange.startOffset) } + } + else { + sequenceOf(TextRange(0, content.length)) + } + + val validEscapes = fstringRanges.flatMap { range -> + PyStringLiteralDecoder.PATTERN_ESCAPE.toRegex() + .findAll(content, range.startOffset) + .takeWhile { it.range.last < range.endOffset } + .map { it.range } + } + .iterator() + val hasValidEscapes = validEscapes.hasNext() + var curValidEscape = if (hasValidEscapes) validEscapes.next() else null + + for (range in fstringRanges) { + var charIndex = range.startOffset + while (charIndex < range.endOffset - 1) { + + if (content[charIndex] != '\\') { + charIndex++ + continue + } + + while (curValidEscape?.let { it.last < charIndex } == true) { + curValidEscape = if (validEscapes.hasNext()) validEscapes.next() else null + } + if (curValidEscape?.contains(charIndex) == true) { + charIndex = curValidEscape.last + 1 + continue + } + + val problemOffset = offsetInNode + charIndex + + val fixes = LocalQuickFix.notNullElements( + PyEscapeBackslashQuickFix(problemOffset), + if (!hasValidEscapes) PyConvertToRawStringQuickFix(problemOffset) else null + ) + + registerProblem( + node, + PyPsiBundle.message("INSP.invalid.escape.sequence", "\\${content[charIndex + 1]}"), + ProblemHighlightType.GENERIC_ERROR_OR_WARNING, + null, + TextRange(problemOffset, problemOffset + 2), + *fixes + ) + charIndex += 2 + } + } + } + } +} diff --git a/python/python-psi-impl/src/com/jetbrains/python/inspections/quickfix/PyConvertToRawStringQuickFix.kt b/python/python-psi-impl/src/com/jetbrains/python/inspections/quickfix/PyConvertToRawStringQuickFix.kt new file mode 100644 index 000000000000..9fe6203d4e75 --- /dev/null +++ b/python/python-psi-impl/src/com/jetbrains/python/inspections/quickfix/PyConvertToRawStringQuickFix.kt @@ -0,0 +1,28 @@ +package com.jetbrains.python.inspections.quickfix + +import com.intellij.codeInsight.intention.LowPriorityAction +import com.intellij.modcommand.ModPsiUpdater +import com.intellij.modcommand.PsiUpdateModCommandQuickFix +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiElement +import com.jetbrains.python.PyPsiBundle +import com.jetbrains.python.psi.PyElementGenerator +import com.jetbrains.python.psi.PyStringLiteralExpression + +class PyConvertToRawStringQuickFix(private val offsetInElement: Int) : PsiUpdateModCommandQuickFix(), LowPriorityAction { + override fun getFamilyName(): String = PyPsiBundle.message("QFIX.convert.to.raw.string") + + override fun applyFix(project: Project, element: PsiElement, updater: ModPsiUpdater) { + if (element !is PyStringLiteralExpression) return + + val part = element.stringElements.find { it.textRangeInParent.contains(offsetInElement) } ?: return + if (part.prefix.contains("r", ignoreCase = true)) return + + val newText = "r" + part.text + + val newPart = PyElementGenerator.getInstance(project) + .createStringLiteralAlreadyEscaped(newText) + .stringElements.firstOrNull() ?: return + part.replace(newPart) + } +} diff --git a/python/python-psi-impl/src/com/jetbrains/python/inspections/quickfix/PyEscapeBackslashQuickFix.kt b/python/python-psi-impl/src/com/jetbrains/python/inspections/quickfix/PyEscapeBackslashQuickFix.kt new file mode 100644 index 000000000000..ebc33be81de3 --- /dev/null +++ b/python/python-psi-impl/src/com/jetbrains/python/inspections/quickfix/PyEscapeBackslashQuickFix.kt @@ -0,0 +1,25 @@ +package com.jetbrains.python.inspections.quickfix + +import com.intellij.modcommand.ModPsiUpdater +import com.intellij.modcommand.PsiUpdateModCommandQuickFix +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiElement +import com.jetbrains.python.PyPsiBundle +import com.jetbrains.python.psi.PyElementGenerator +import com.jetbrains.python.psi.PyStringLiteralExpression + +class PyEscapeBackslashQuickFix(private val offsetInElement: Int) : PsiUpdateModCommandQuickFix() { + override fun getFamilyName(): String = PyPsiBundle.message("QFIX.escape.backslash") + + override fun applyFix(project: Project, element: PsiElement, updater: ModPsiUpdater) { + if (element !is PyStringLiteralExpression) return + + if (offsetInElement >= element.textLength || element.text[offsetInElement] != '\\') return + + val newText = StringBuilder(element.text).insert(offsetInElement, "\\").toString() + + val newElement = PyElementGenerator.getInstance(project) + .createStringLiteralAlreadyEscaped(newText) + element.replace(newElement) + } +} diff --git a/python/testData/inspections/PyInvalidEscapeSequenceInspection/basic.py b/python/testData/inspections/PyInvalidEscapeSequenceInspection/basic.py new file mode 100644 index 000000000000..b85d676f8cef --- /dev/null +++ b/python/testData/inspections/PyInvalidEscapeSequenceInspection/basic.py @@ -0,0 +1,9 @@ +s1 = "\." + +s2 = "\z" + +s3 = "\a \b \f \n \r \t \v \\ \' \" \0 \x41 \u1234 \U00012345 \N{SNOWMAN}" + +s4 = r"\." + +s5 = " \. \z " diff --git a/python/testData/inspections/PyInvalidEscapeSequenceInspection/bytes.py b/python/testData/inspections/PyInvalidEscapeSequenceInspection/bytes.py new file mode 100644 index 000000000000..e2ab04f7d2ce --- /dev/null +++ b/python/testData/inspections/PyInvalidEscapeSequenceInspection/bytes.py @@ -0,0 +1,9 @@ +b1 = b"\." + +b2 = b"\z" + +b3 = b"\a \b \f \n \r \t \v \\ \' \" \0 \x41 \u1234 \U00012345 \N{SNOWMAN}" + +b4 = rb"\." + +b5 = b" \. \z " diff --git a/python/testData/inspections/PyInvalidEscapeSequenceInspection/convertToRawStringNotAvailable_before.py b/python/testData/inspections/PyInvalidEscapeSequenceInspection/convertToRawStringNotAvailable_before.py new file mode 100644 index 000000000000..9b72b5d1c3bc --- /dev/null +++ b/python/testData/inspections/PyInvalidEscapeSequenceInspection/convertToRawStringNotAvailable_before.py @@ -0,0 +1 @@ +s = "postfix" '\n\.' "suffix" diff --git a/python/testData/inspections/PyInvalidEscapeSequenceInspection/convertToRawString_after.py b/python/testData/inspections/PyInvalidEscapeSequenceInspection/convertToRawString_after.py new file mode 100644 index 000000000000..0a357fc72cd9 --- /dev/null +++ b/python/testData/inspections/PyInvalidEscapeSequenceInspection/convertToRawString_after.py @@ -0,0 +1 @@ +s = "prefix" r'\.' "suffix" diff --git a/python/testData/inspections/PyInvalidEscapeSequenceInspection/convertToRawString_before.py b/python/testData/inspections/PyInvalidEscapeSequenceInspection/convertToRawString_before.py new file mode 100644 index 000000000000..8cbd9269d7b0 --- /dev/null +++ b/python/testData/inspections/PyInvalidEscapeSequenceInspection/convertToRawString_before.py @@ -0,0 +1 @@ +s = "prefix" '\.' "suffix" diff --git a/python/testData/inspections/PyInvalidEscapeSequenceInspection/escapeBackslash_after.py b/python/testData/inspections/PyInvalidEscapeSequenceInspection/escapeBackslash_after.py new file mode 100644 index 000000000000..9a6940d661bc --- /dev/null +++ b/python/testData/inspections/PyInvalidEscapeSequenceInspection/escapeBackslash_after.py @@ -0,0 +1 @@ +s = "prefix" '\\.' "suffix" diff --git a/python/testData/inspections/PyInvalidEscapeSequenceInspection/escapeBackslash_before.py b/python/testData/inspections/PyInvalidEscapeSequenceInspection/escapeBackslash_before.py new file mode 100644 index 000000000000..8cbd9269d7b0 --- /dev/null +++ b/python/testData/inspections/PyInvalidEscapeSequenceInspection/escapeBackslash_before.py @@ -0,0 +1 @@ +s = "prefix" '\.' "suffix" diff --git a/python/testData/inspections/PyInvalidEscapeSequenceInspection/fstrings.py b/python/testData/inspections/PyInvalidEscapeSequenceInspection/fstrings.py new file mode 100644 index 000000000000..c3c162c2d300 --- /dev/null +++ b/python/testData/inspections/PyInvalidEscapeSequenceInspection/fstrings.py @@ -0,0 +1,44 @@ +f1 = f"\." + +f2 = f"\a \b \f \n \r \t \v \\ \' \" \0 \x41 \u1234 \U00012345 \N{SNOWMAN}" + +f3 = f"foo {1} \ " + +f4 = f"foo { f'\.' }" + +f5 = f"level1 { f'level2 { f"level3 \." }' } \." + +f6 = f"valid \n invalid \. valid \t" + +f7 = f"{{ \. }}" + +f8 = f"\{{" + +f9 = f""" + \. + {1} + valid \n + \z +""" + +f10 = f"text \. { '\n' } text" + +f11 = f"list {[1, 2]} set { {1, 2} } dict { {'k': 'v'} } \." + +# \n and \x are escape sequences, while \z is not: +f12 = f"expression { 1 + \ +n } \." + +f13 = f"nested { f'inner { 1 + \ +x }' } " + +f16 = f"expression { 1 + \ +z } \." + +f14 = f" { '\n' } { '\.' } " + +f15 = f" { {'key': '\n', 'val': '\.'} } " + +rf1 = rf"\." +rf2 = rf"foo {1} \." +rf3 = fr"foo {1} \." diff --git a/python/testSrc/com/jetbrains/python/inspections/PyInvalidEscapeSequenceInspectionTest.kt b/python/testSrc/com/jetbrains/python/inspections/PyInvalidEscapeSequenceInspectionTest.kt new file mode 100644 index 000000000000..4466c09a72c2 --- /dev/null +++ b/python/testSrc/com/jetbrains/python/inspections/PyInvalidEscapeSequenceInspectionTest.kt @@ -0,0 +1,43 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.inspections + +import com.jetbrains.python.PyPsiBundle +import com.jetbrains.python.fixtures.PyInspectionTestCase + +class PyInvalidEscapeSequenceInspectionTest : PyInspectionTestCase() { + + override fun getInspectionClass(): Class = PyInvalidEscapeSequenceInspection::class.java + + fun testBasic() = doTest() + + fun testBytes() = doTest() + + fun testFstrings() = doTest() + + fun testEscapeBackslash() { + doQuickFixTest(PyPsiBundle.message("QFIX.escape.backslash"), "escapeBackslash_before.py", "escapeBackslash_after.py") + } + + fun testConvertToRawString() { + doQuickFixTest(PyPsiBundle.message("QFIX.convert.to.raw.string"), "convertToRawString_before.py", "convertToRawString_after.py") + } + + fun testConvertToRawStringNotAvailable() { + myFixture.configureByFile(testCaseDirectory + "convertToRawStringNotAvailable_before.py") + myFixture.enableInspections(getInspectionClass()) + + val actions = myFixture.filterAvailableIntentions(PyPsiBundle.message("QFIX.convert.to.raw.string")) + assertEmpty("Convert to raw string should not be available if string contains valid escapes", actions) + + assertNotNull(myFixture.findSingleIntention(PyPsiBundle.message("QFIX.escape.backslash"))) + } + + private fun doQuickFixTest(hint: String, sourceFile: String, expectedFile: String) { + myFixture.configureByFile(testCaseDirectory + sourceFile) + myFixture.enableInspections(getInspectionClass()) + + val action = myFixture.findSingleIntention(hint) + myFixture.launchAction(action) + myFixture.checkResultByFile(testCaseDirectory + expectedFile) + } +}