[python] PY-85601 Add "invalid escape sequence" inspection

GitOrigin-RevId: d750f06efc5e42678073de084ecb2976acedd4b9
This commit is contained in:
Nikita Paniukhin
2025-12-02 21:55:58 +00:00
committed by intellij-monorepo-bot
parent 3e223423f2
commit 788e8300e4
15 changed files with 285 additions and 0 deletions
@@ -0,0 +1,22 @@
<html>
<body>
Reports invalid escape sequences in string and bytes literals.
<p>
Starting from Python 3.6, escape sequences that are not recognized (such as <code>\.</code>) produce a <code>SyntaxWarning</code>.
In future Python versions, they will become a <code>SyntaxError</code>.
</p>
<p>
To fix this, you can either escape the backslash (e.g., <code>\\.</code>) or use a raw string (e.g., <code>r'\.'</code>).
</p>
<p><b>Example:</b></p>
<pre><code>
# Warning: Invalid escape sequence '\.'
print('\.')
# Correct
print(r'\.')
print('\\.')
</code></pre>
</body>
</html>
@@ -269,6 +269,7 @@
<localInspection language="Python" shortName="PyAssertTypeInspection" suppressId="PyAssertType" bundle="messages.PyPsiBundle" key="INSP.NAME.assert.type" groupKey="INSP.GROUP.python" enabledByDefault="true" level="WARNING" implementationClass="com.jetbrains.python.inspections.PyAssertTypeInspection"/>
<localInspection language="Python" shortName="PyEnumInspection" suppressId="PyEnum" bundle="messages.PyPsiBundle" key="INSP.NAME.enum" groupKey="INSP.GROUP.python" enabledByDefault="true" level="WARNING" implementationClass="com.jetbrains.python.inspections.PyEnumInspection"/>
<localInspection language="Python" shortName="PyNewTypeInspection" suppressId="PyNewType" bundle="messages.PyPsiBundle" key="INSP.NAME.new.type" groupKey="INSP.GROUP.python" enabledByDefault="true" level="WARNING" implementationClass="com.jetbrains.python.inspections.PyNewTypeInspection"/>
<localInspection language="Python" shortName="PyInvalidEscapeSequenceInspection" suppressId="PyInvalidEscapeSequence" bundle="messages.PyPsiBundle" key="INSP.NAME.invalid.escape.sequence" groupKey="INSP.GROUP.python" enabledByDefault="true" level="WARNING" implementationClass="com.jetbrains.python.inspections.PyInvalidEscapeSequenceInspection"/>
<intentionAction>
<language>Python</language>
@@ -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
@@ -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
}
}
}
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -0,0 +1,9 @@
s1 = "<warning descr="Invalid escape sequence '\.'">\.</warning>"
s2 = "<warning descr="Invalid escape sequence '\z'">\z</warning>"
s3 = "\a \b \f \n \r \t \v \\ \' \" \0 \x41 \u1234 \U00012345 \N{SNOWMAN}"
s4 = r"\."
s5 = " <warning descr="Invalid escape sequence '\.'">\.</warning> <warning descr="Invalid escape sequence '\z'">\z</warning> "
@@ -0,0 +1,9 @@
b1 = b"<warning descr="Invalid escape sequence '\.'">\.</warning>"
b2 = b"<warning descr="Invalid escape sequence '\z'">\z</warning>"
b3 = b"\a \b \f \n \r \t \v \\ \' \" \0 \x41 \u1234 \U00012345 \N{SNOWMAN}"
b4 = rb"\."
b5 = b" <warning descr="Invalid escape sequence '\.'">\.</warning> <warning descr="Invalid escape sequence '\z'">\z</warning> "
@@ -0,0 +1 @@
s = "postfix" '\n<caret>\.' "suffix"
@@ -0,0 +1 @@
s = "prefix" r'\.' "suffix"
@@ -0,0 +1 @@
s = "prefix" '<caret>\.' "suffix"
@@ -0,0 +1 @@
s = "prefix" '\\.' "suffix"
@@ -0,0 +1 @@
s = "prefix" '<caret>\.' "suffix"
@@ -0,0 +1,44 @@
f1 = f"<warning descr="Invalid escape sequence '\.'">\.</warning>"
f2 = f"\a \b \f \n \r \t \v \\ \' \" \0 \x41 \u1234 \U00012345 \N{SNOWMAN}"
f3 = f"foo {1} <warning descr="Invalid escape sequence '\ '">\ </warning>"
f4 = f"foo { f'<warning descr="Invalid escape sequence '\.'">\.</warning>' }"
f5 = f"level1 { f'level2 { f"level3 <warning descr="Invalid escape sequence '\.'">\.</warning>" }' } <warning descr="Invalid escape sequence '\.'">\.</warning>"
f6 = f"valid \n invalid <warning descr="Invalid escape sequence '\.'">\.</warning> valid \t"
f7 = f"{{ <warning descr="Invalid escape sequence '\.'">\.</warning> }}"
f8 = f"<warning descr="Invalid escape sequence '\{'">\{</warning>{"
f9 = f"""
<warning descr="Invalid escape sequence '\.'">\.</warning>
{1}
valid \n
<warning descr="Invalid escape sequence '\z'">\z</warning>
"""
f10 = f"text <warning descr="Invalid escape sequence '\.'">\.</warning> { '\n' } text"
f11 = f"list {[1, 2]} set { {1, 2} } dict { {'k': 'v'} } <warning descr="Invalid escape sequence '\.'">\.</warning>"
# \n and \x are escape sequences, while \z is not:
f12 = f"expression { 1 + \
n } <warning descr="Invalid escape sequence '\.'">\.</warning>"
f13 = f"nested { f'inner { 1 + \
x }' } "
f16 = f"expression { 1 + \
z } <warning descr="Invalid escape sequence '\.'">\.</warning>"
f14 = f" { '\n' } { '<warning descr="Invalid escape sequence '\.'">\.</warning>' } "
f15 = f" { {'key': '\n', 'val': '<warning descr="Invalid escape sequence '\.'">\.</warning>'} } "
rf1 = rf"\."
rf2 = rf"foo {1} \."
rf3 = fr"foo {1} \."
@@ -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<out PyInspection> = 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)
}
}