diff --git a/python/python-psi-impl/resources/messages/PyPsiBundle.properties b/python/python-psi-impl/resources/messages/PyPsiBundle.properties index 1158d52834ee..cbbe50cb0767 100644 --- a/python/python-psi-impl/resources/messages/PyPsiBundle.properties +++ b/python/python-psi-impl/resources/messages/PyPsiBundle.properties @@ -1370,7 +1370,7 @@ INSP.NAME.patterns=Invalid pattern in pattern matching INSP.patterns.pattern.can.be.simplified=Pattern can be simplified INSP.patterns.class.does.not.support.pattern.matching.with.positional.arguments=Class {0} does not support pattern matching with positional arguments INSP.patterns.too.many.positional.patterns.expected=Too many positional patterns, expected {0} -INSP.patterns.attribute.already.specified.as.positional.pattern.at.position=Attribute ''{0}'' is already specified as positional pattern at position {1}1 +INSP.patterns.attribute.already.specified.as.positional.pattern.at.position=Attribute ''{0}'' is already specified as positional pattern at position {1} QFIX.simplify.as.pattern=Simplify 'as' pattern QFIX.NAME.remove.list.member=Remove list member QFIX.remove.pattern=Remove pattern diff --git a/python/python-psi-impl/src/com/jetbrains/python/inspections/PyPatternInspection.kt b/python/python-psi-impl/src/com/jetbrains/python/inspections/PyPatternInspection.kt index 418ce40d87b6..4798d67aec7b 100644 --- a/python/python-psi-impl/src/com/jetbrains/python/inspections/PyPatternInspection.kt +++ b/python/python-psi-impl/src/com/jetbrains/python/inspections/PyPatternInspection.kt @@ -12,6 +12,7 @@ import com.intellij.psi.util.PsiTreeUtil import com.jetbrains.python.PyNames import com.jetbrains.python.PyPsiBundle import com.jetbrains.python.PyTokenTypes +import com.jetbrains.python.documentation.PythonDocumentationProvider import com.jetbrains.python.psi.* import com.jetbrains.python.psi.impl.PyBuiltinCache import com.jetbrains.python.psi.impl.PyClassPatternImpl @@ -83,7 +84,7 @@ private class PyPatternInspectionVisitor(holder: ProblemsHolder, context: TypeEv val keywordName = (keywordPattern as PyKeywordPattern).keyword val positionalIndex = positionalAttributeNames.indexOf(keywordName) if (positionalIndex >= 0) { - holder.problem(keywordPattern, PyPsiBundle.message("INSP.patterns.attribute.already.specified.as.positional.pattern.at.position", keywordName, positionalIndex)) + holder.problem(keywordPattern, PyPsiBundle.message("INSP.patterns.attribute.already.specified.as.positional.pattern.at.position", keywordName, positionalIndex + 1)) .fix(RemoveListMemberFix(keywordPattern)) .register() } @@ -96,12 +97,16 @@ private class PyPatternInspectionVisitor(holder: ProblemsHolder, context: TypeEv ?.findAssignedValue() ?.let { PyPsiUtils.flattenParens(it) } ?: return - val matchArgsType = myTypeEvalContext.getType(matchArgs) - val strType = PyBuiltinCache.getInstance(matchArgs).strType - val goodTuple = PyTupleType.createHomogeneous(matchArgs, strType) + val matchArgsType = myTypeEvalContext.getType(matchArgs) ?: return + val strType = PyBuiltinCache.getInstance(matchArgs).strType ?: return + val goodTuple = PyTupleType.createHomogeneous(matchArgs, strType) ?: return if (PyTypeChecker.match(goodTuple, matchArgsType, myTypeEvalContext)) return // __match_args__ must be a tuple[str, ...] - holder.problem(matchArgs, "__match_args__ must be a tuple[str, ...]").register() + holder.problem(matchArgs, PyPsiBundle.message( + "INSP.type.checker.expected.type.got.type.instead", + PythonDocumentationProvider.getTypeName(goodTuple, myTypeEvalContext), + PythonDocumentationProvider.getTypeName(matchArgsType, myTypeEvalContext)) + ).register() } } @@ -167,7 +172,11 @@ class AddMatchArgsFix(element: PyClass) : PsiUpdateModCommandAction(ele ?.toList() ?: emptyList() val generator = PyElementGenerator.getInstance(pyClass.project) - val matchArgsValue = "(" + positionalArgs.joinToString(", ") { "'$it'" } + ")" + val matchArgsValue = positionalArgs.joinToString( + prefix = "(", + postfix = if (positionalArgs.size != 1) ")" else ",)", + separator = ", " + ) { "'$it'" } val matchArgsAssignment = generator.createFromText( LanguageLevel.forElement(pyClass), PyAssignmentStatement::class.java, diff --git a/python/python-psi-impl/src/com/jetbrains/python/psi/impl/PyClassPatternImpl.java b/python/python-psi-impl/src/com/jetbrains/python/psi/impl/PyClassPatternImpl.java index 0edce86d9335..0675a05364fa 100644 --- a/python/python-psi-impl/src/com/jetbrains/python/psi/impl/PyClassPatternImpl.java +++ b/python/python-psi-impl/src/com/jetbrains/python/psi/impl/PyClassPatternImpl.java @@ -137,15 +137,25 @@ public class PyClassPatternImpl extends PyElementImpl implements PyClassPattern, } public static @Nullable List<@NotNull String> getMatchArgs(@NotNull PyClassType type, @NotNull TypeEvalContext context) { - List matchArgs = type.getPyClass().getOwnMatchArgs(); - if (matchArgs == null) { - matchArgs = PyNamedTupleTypeProvider.Companion.getGeneratedMatchArgs(type, context); - } - if (matchArgs == null) { - matchArgs = PyDataclassTypeProvider.Companion.getGeneratedMatchArgs(type, context); + final PyClass cls = type.getPyClass(); + // TODO: change to getMemberType, when PyLiteralType can be created without PyExpression + + List matchArgs = cls.getOwnMatchArgs(); + if (matchArgs != null) return matchArgs; + + matchArgs = PyNamedTupleTypeProvider.Companion.getGeneratedMatchArgs(type, context); + if (matchArgs != null) return matchArgs; + matchArgs = PyDataclassTypeProvider.Companion.getGeneratedMatchArgs(type, context); + if (matchArgs != null) return matchArgs; + + for (PyClassLikeType baseType : type.getSuperClassTypes(context)) { + if (baseType instanceof PyClassType baseClassType) { + final List inherited = getMatchArgs(baseClassType, context); + if (inherited != null) return inherited; + } } - return matchArgs; + return null; } @Nullable diff --git a/python/testData/quickFixes/PyAddMatchArgsFixTest/addMatchArgsBasic.py b/python/testData/quickFixes/PyAddMatchArgsFixTest/addMatchArgsBasic.py new file mode 100644 index 000000000000..de333a2a3d20 --- /dev/null +++ b/python/testData/quickFixes/PyAddMatchArgsFixTest/addMatchArgsBasic.py @@ -0,0 +1,9 @@ +class Point: + def __init__(self, x, y): + self.x = x + self.y = y + +def f(p): + match p: + case Point(1, 2): + pass diff --git a/python/testData/quickFixes/PyAddMatchArgsFixTest/addMatchArgsBasic_after.py b/python/testData/quickFixes/PyAddMatchArgsFixTest/addMatchArgsBasic_after.py new file mode 100644 index 000000000000..dddda214f0f9 --- /dev/null +++ b/python/testData/quickFixes/PyAddMatchArgsFixTest/addMatchArgsBasic_after.py @@ -0,0 +1,11 @@ +class Point: + __match_args__ = ('x', 'y') + + def __init__(self, x, y): + self.x = x + self.y = y + +def f(p): + match p: + case Point(1, 2): + pass diff --git a/python/testData/quickFixes/PyAddMatchArgsFixTest/addMatchArgsFilterAttributes.py b/python/testData/quickFixes/PyAddMatchArgsFixTest/addMatchArgsFilterAttributes.py new file mode 100644 index 000000000000..4b4adf08a7fe --- /dev/null +++ b/python/testData/quickFixes/PyAddMatchArgsFixTest/addMatchArgsFilterAttributes.py @@ -0,0 +1,9 @@ +class C: + def __init__(self, a, b, c): + self.a = a + # b is not assigned to instance attribute + +def f(obj): + match obj: + case C(1, 2): + pass diff --git a/python/testData/quickFixes/PyAddMatchArgsFixTest/addMatchArgsFilterAttributes_after.py b/python/testData/quickFixes/PyAddMatchArgsFixTest/addMatchArgsFilterAttributes_after.py new file mode 100644 index 000000000000..2a32d5347da3 --- /dev/null +++ b/python/testData/quickFixes/PyAddMatchArgsFixTest/addMatchArgsFilterAttributes_after.py @@ -0,0 +1,11 @@ +class C: + __match_args__ = ('a',) + + def __init__(self, a, b, c): + self.a = a + # b is not assigned to instance attribute + +def f(obj): + match obj: + case C(1, 2): + pass diff --git a/python/testData/quickFixes/PyAddMatchArgsFixTest/addMatchArgsNoInit.py b/python/testData/quickFixes/PyAddMatchArgsFixTest/addMatchArgsNoInit.py new file mode 100644 index 000000000000..b4e5f5df68ed --- /dev/null +++ b/python/testData/quickFixes/PyAddMatchArgsFixTest/addMatchArgsNoInit.py @@ -0,0 +1,7 @@ +class Empty: + pass + +def f(obj): + match obj: + case Empty(1): + pass diff --git a/python/testData/quickFixes/PyAddMatchArgsFixTest/addMatchArgsNoInit_after.py b/python/testData/quickFixes/PyAddMatchArgsFixTest/addMatchArgsNoInit_after.py new file mode 100644 index 000000000000..287af9cea6e5 --- /dev/null +++ b/python/testData/quickFixes/PyAddMatchArgsFixTest/addMatchArgsNoInit_after.py @@ -0,0 +1,8 @@ +class Empty: + __match_args__ = () + pass + +def f(obj): + match obj: + case Empty(1): + pass diff --git a/python/testData/quickFixes/PyAddMatchArgsFixTest/addMatchArgsOnlySelf.py b/python/testData/quickFixes/PyAddMatchArgsFixTest/addMatchArgsOnlySelf.py new file mode 100644 index 000000000000..3ca38f1454fb --- /dev/null +++ b/python/testData/quickFixes/PyAddMatchArgsFixTest/addMatchArgsOnlySelf.py @@ -0,0 +1,8 @@ +class OnlySelf: + def __init__(self): + pass + +def f(obj): + match obj: + case OnlySelf(1): + pass diff --git a/python/testData/quickFixes/PyAddMatchArgsFixTest/addMatchArgsOnlySelf_after.py b/python/testData/quickFixes/PyAddMatchArgsFixTest/addMatchArgsOnlySelf_after.py new file mode 100644 index 000000000000..1bc4cc4d3a2d --- /dev/null +++ b/python/testData/quickFixes/PyAddMatchArgsFixTest/addMatchArgsOnlySelf_after.py @@ -0,0 +1,10 @@ +class OnlySelf: + __match_args__ = () + + def __init__(self): + pass + +def f(obj): + match obj: + case OnlySelf(1): + pass diff --git a/python/testData/quickFixes/PyRemovePatternFixTest/removeConflictingKeyword.py b/python/testData/quickFixes/PyRemovePatternFixTest/removeConflictingKeyword.py new file mode 100644 index 000000000000..0d52ce73d941 --- /dev/null +++ b/python/testData/quickFixes/PyRemovePatternFixTest/removeConflictingKeyword.py @@ -0,0 +1,10 @@ +class Point: + __match_args__ = ("x", "y") + def __init__(self, x, y): + self.x = x + self.y = y + +def f(p): + match p: + case Point(0, x=0): + pass diff --git a/python/testData/quickFixes/PyRemovePatternFixTest/removeConflictingKeyword_after.py b/python/testData/quickFixes/PyRemovePatternFixTest/removeConflictingKeyword_after.py new file mode 100644 index 000000000000..494cace517a9 --- /dev/null +++ b/python/testData/quickFixes/PyRemovePatternFixTest/removeConflictingKeyword_after.py @@ -0,0 +1,10 @@ +class Point: + __match_args__ = ("x", "y") + def __init__(self, x, y): + self.x = x + self.y = y + +def f(p): + match p: + case Point(0): + pass diff --git a/python/testData/quickFixes/PyRemovePatternFixTest/removeLastExtraPositional.py b/python/testData/quickFixes/PyRemovePatternFixTest/removeLastExtraPositional.py new file mode 100644 index 000000000000..ebbeb8ffc19a --- /dev/null +++ b/python/testData/quickFixes/PyRemovePatternFixTest/removeLastExtraPositional.py @@ -0,0 +1,10 @@ +class Point: + __match_args__ = ("x", "y") + def __init__(self, x, y): + self.x = x + self.y = y + +def f(p): + match p: + case Point(a, b, c): + pass diff --git a/python/testData/quickFixes/PyRemovePatternFixTest/removeLastExtraPositional_after.py b/python/testData/quickFixes/PyRemovePatternFixTest/removeLastExtraPositional_after.py new file mode 100644 index 000000000000..ecd4b5ddcc5f --- /dev/null +++ b/python/testData/quickFixes/PyRemovePatternFixTest/removeLastExtraPositional_after.py @@ -0,0 +1,10 @@ +class Point: + __match_args__ = ("x", "y") + def __init__(self, x, y): + self.x = x + self.y = y + +def f(p): + match p: + case Point(a, b): + pass diff --git a/python/testData/quickFixes/PyRemovePatternFixTest/removeSingleExtraPositional.py b/python/testData/quickFixes/PyRemovePatternFixTest/removeSingleExtraPositional.py new file mode 100644 index 000000000000..31097c122b18 --- /dev/null +++ b/python/testData/quickFixes/PyRemovePatternFixTest/removeSingleExtraPositional.py @@ -0,0 +1,7 @@ +class Z: + __match_args__ = () + +def f(z): + match z: + case Z(1): + pass diff --git a/python/testData/quickFixes/PyRemovePatternFixTest/removeSingleExtraPositional_after.py b/python/testData/quickFixes/PyRemovePatternFixTest/removeSingleExtraPositional_after.py new file mode 100644 index 000000000000..200d91ac0b9a --- /dev/null +++ b/python/testData/quickFixes/PyRemovePatternFixTest/removeSingleExtraPositional_after.py @@ -0,0 +1,7 @@ +class Z: + __match_args__ = () + +def f(z): + match z: + case Z(): + pass diff --git a/python/testData/quickFixes/PySimplifyAsPatternFixTest/simplifyIntAsPattern.py b/python/testData/quickFixes/PySimplifyAsPatternFixTest/simplifyIntAsPattern.py new file mode 100644 index 000000000000..b3ba79c97387 --- /dev/null +++ b/python/testData/quickFixes/PySimplifyAsPatternFixTest/simplifyIntAsPattern.py @@ -0,0 +1,5 @@ +x = 42 + +match x: + case int() as n: + pass diff --git a/python/testData/quickFixes/PySimplifyAsPatternFixTest/simplifyIntAsPattern_after.py b/python/testData/quickFixes/PySimplifyAsPatternFixTest/simplifyIntAsPattern_after.py new file mode 100644 index 000000000000..bfb667ac6679 --- /dev/null +++ b/python/testData/quickFixes/PySimplifyAsPatternFixTest/simplifyIntAsPattern_after.py @@ -0,0 +1,5 @@ +x = 42 + +match x: + case int(n): + pass diff --git a/python/testData/quickFixes/PySimplifyAsPatternFixTest/simplifyListAsPattern.py b/python/testData/quickFixes/PySimplifyAsPatternFixTest/simplifyListAsPattern.py new file mode 100644 index 000000000000..c623dac2b4eb --- /dev/null +++ b/python/testData/quickFixes/PySimplifyAsPatternFixTest/simplifyListAsPattern.py @@ -0,0 +1,5 @@ +x = [] + +match x: + case list() as xs: + pass diff --git a/python/testData/quickFixes/PySimplifyAsPatternFixTest/simplifyListAsPattern_after.py b/python/testData/quickFixes/PySimplifyAsPatternFixTest/simplifyListAsPattern_after.py new file mode 100644 index 000000000000..57dc8e9aecf6 --- /dev/null +++ b/python/testData/quickFixes/PySimplifyAsPatternFixTest/simplifyListAsPattern_after.py @@ -0,0 +1,5 @@ +x = [] + +match x: + case list(xs): + pass diff --git a/python/testSrc/com/jetbrains/python/inspections/PyPatternInspectionTest.java b/python/testSrc/com/jetbrains/python/inspections/PyPatternInspectionTest.java index 1c77664ad83c..342f95700cbe 100644 --- a/python/testSrc/com/jetbrains/python/inspections/PyPatternInspectionTest.java +++ b/python/testSrc/com/jetbrains/python/inspections/PyPatternInspectionTest.java @@ -55,16 +55,7 @@ def f(p): public void testInvalidMatchArgsType() { doTestByText(""" class D: - __match_args__ = 42 - """); - } - - public void testUnknownAttributeInMatchArgs() { - doTestByText(""" -class E: - def __init__(self): - self.x = 0 - __match_args__ = ("x", 'y') + __match_args__ = 42 """); } @@ -140,7 +131,7 @@ class A: def f(a): match a: - case A(x, y, z): + case A(x, y, z): pass """); } @@ -150,7 +141,7 @@ def f(a): class D: def __init__(self): self.x = 0 - __match_args__ = ("x", 1) + __match_args__ = ("x", 1) """); } @@ -233,6 +224,105 @@ class E: """); } + public void testSimplifyAsPatternInt() { + doTestByText(""" +x = 42 + +match x: + case int() as n: + pass + """); + } + + public void testSimplifyAsPatternDict() { + doTestByText(""" +x = {} + +match x: + case dict() as d: + pass + """); + } + + public void testSimplifyAsPatternWithArgumentsNoWarning() { + doTestByText(""" +x = [] + +match x: + case list(1, 2) as xs: + pass + """); + } + + public void testMatchArgsInvalidTypeList() { + doTestByText(""" +class D: + __match_args__ = ["x", "y"] + """); + } + + public void testMatchArgsInvalidTypeSet() { + doTestByText(""" +class D: + __match_args__ = {"x", "y"} + """); + } + + public void testMatchArgsInvalidTypeNone() { + doTestByText(""" +class D: + __match_args__ = None + """); + } + + public void testMatchArgsInvalidTupleOfInts() { + doTestByText(""" +class D: + __match_args__ = (1, 2, 3) + """); + } + + public void testInheritedMatchArgsFromBase() { + doTestByText(""" +class Base: + __match_args__ = ("x",) + def __init__(self, x): + self.x = x + +class Derived(Base): + pass + +def f(d): + match d: + case Derived(1): + pass + """); + } + + public void testClassWithoutInitNoWarning() { + doTestByText(""" +class C: + pass + +def f(c): + match c: + case C(a=1): + pass + """); + } + + public void testEmptyClassWithPositionalPatternWarning() { + doTestByText(""" +class C: + pass + +def f(c): + match c: + case C(1): + pass + """); + } + @NotNull @Override protected Class getInspectionClass() { diff --git a/python/testSrc/com/jetbrains/python/quickFixes/PyAddMatchArgsFixTest.kt b/python/testSrc/com/jetbrains/python/quickFixes/PyAddMatchArgsFixTest.kt new file mode 100644 index 000000000000..e8083730b913 --- /dev/null +++ b/python/testSrc/com/jetbrains/python/quickFixes/PyAddMatchArgsFixTest.kt @@ -0,0 +1,26 @@ +// 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.quickFixes + +import com.intellij.testFramework.TestDataPath +import com.jetbrains.python.PyPsiBundle +import com.jetbrains.python.PyQuickFixTestCase +import com.jetbrains.python.inspections.PyPatternInspection + +@TestDataPath("\$CONTENT_ROOT/../testData/quickFixes/PyAddMatchArgsFixTest") +class PyAddMatchArgsFixTest : PyQuickFixTestCase() { + fun testAddMatchArgsBasic() { + doQuickFixTest(PyPatternInspection::class.java, PyPsiBundle.message("QFIX.add.match.args.to.class", "Point")) + } + + fun testAddMatchArgsFilterAttributes() { + doQuickFixTest(PyPatternInspection::class.java, PyPsiBundle.message("QFIX.add.match.args.to.class", "C")) + } + + fun testAddMatchArgsNoInit() { + doQuickFixTest(PyPatternInspection::class.java, PyPsiBundle.message("QFIX.add.match.args.to.class", "Empty")) + } + + fun testAddMatchArgsOnlySelf() { + doQuickFixTest(PyPatternInspection::class.java, PyPsiBundle.message("QFIX.add.match.args.to.class", "OnlySelf")) + } +} diff --git a/python/testSrc/com/jetbrains/python/quickFixes/PyRemovePatternFixTest.kt b/python/testSrc/com/jetbrains/python/quickFixes/PyRemovePatternFixTest.kt new file mode 100644 index 000000000000..29b206dc3f20 --- /dev/null +++ b/python/testSrc/com/jetbrains/python/quickFixes/PyRemovePatternFixTest.kt @@ -0,0 +1,22 @@ +// 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.quickFixes + +import com.intellij.testFramework.TestDataPath +import com.jetbrains.python.PyPsiBundle +import com.jetbrains.python.PyQuickFixTestCase +import com.jetbrains.python.inspections.PyPatternInspection + +@TestDataPath($$"$CONTENT_ROOT/../testData/quickFixes/PyRemovePatternFixTest") +class PyRemovePatternFixTest : PyQuickFixTestCase() { + fun testRemoveLastExtraPositional() { + doQuickFixTest(PyPatternInspection::class.java, PyPsiBundle.message("QFIX.remove.pattern")) + } + + fun testRemoveConflictingKeyword() { + doQuickFixTest(PyPatternInspection::class.java, PyPsiBundle.message("QFIX.remove.pattern")) + } + + fun testRemoveSingleExtraPositional() { + doQuickFixTest(PyPatternInspection::class.java, PyPsiBundle.message("QFIX.remove.pattern")) + } +} diff --git a/python/testSrc/com/jetbrains/python/quickFixes/PySimplifyAsPatternFixTest.kt b/python/testSrc/com/jetbrains/python/quickFixes/PySimplifyAsPatternFixTest.kt new file mode 100644 index 000000000000..c453e98b1184 --- /dev/null +++ b/python/testSrc/com/jetbrains/python/quickFixes/PySimplifyAsPatternFixTest.kt @@ -0,0 +1,30 @@ +// 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.quickFixes + +import com.intellij.testFramework.TestDataPath +import com.jetbrains.python.PyPsiBundle +import com.jetbrains.python.PyQuickFixTestCase +import com.jetbrains.python.inspections.PyPatternInspection + +@TestDataPath("\$CONTENT_ROOT/../testData/quickFixes/PySimplifyAsPatternFixTest") +class PySimplifyAsPatternFixTest : PyQuickFixTestCase() { + fun testSimplifyListAsPattern() { + doQuickFixTest(PyPsiBundle.message("QFIX.simplify.as.pattern")) + } + + fun testSimplifyIntAsPattern() { + doQuickFixTest(PyPsiBundle.message("QFIX.simplify.as.pattern")) + } + + // Overriden to enable weak warnings + override fun doQuickFixTest(hint: String) { + val testFileName = getTestName(true) + myFixture.enableInspections(PyPatternInspection::class.java) + myFixture.configureByFile("$testFileName.py") + myFixture.checkHighlighting(false, false, true) + val intentionAction = myFixture.findSingleIntention(hint) + assertNotNull(intentionAction) + myFixture.launchAction(intentionAction) + myFixture.checkResultByFile(testFileName + "_after.py", true) + } +}