diff --git a/python/python-psi-impl/resources/inspectionDescriptions/PyUnusedFunctionInspection.html b/python/python-psi-impl/resources/inspectionDescriptions/PyUnusedFunctionInspection.html new file mode 100644 index 000000000000..14b4b27049e3 --- /dev/null +++ b/python/python-psi-impl/resources/inspectionDescriptions/PyUnusedFunctionInspection.html @@ -0,0 +1,7 @@ + + +

+ Reports local functions that are defined but never used. +

+ + diff --git a/python/python-psi-impl/resources/inspectionDescriptions/PyUnusedLocalInspection.html b/python/python-psi-impl/resources/inspectionDescriptions/PyUnusedLocalInspection.html index 6cc9ba8651e0..d1f995499f7c 100644 --- a/python/python-psi-impl/resources/inspectionDescriptions/PyUnusedLocalInspection.html +++ b/python/python-psi-impl/resources/inspectionDescriptions/PyUnusedLocalInspection.html @@ -1,7 +1,7 @@

- Reports local variables, parameters, and functions that are locally defined, but not used name in a function. + Reports local variables and other local symbols that are locally defined, but not used name in a function.

diff --git a/python/python-psi-impl/resources/inspectionDescriptions/PyUnusedParameterInspection.html b/python/python-psi-impl/resources/inspectionDescriptions/PyUnusedParameterInspection.html new file mode 100644 index 000000000000..fdf591291fd4 --- /dev/null +++ b/python/python-psi-impl/resources/inspectionDescriptions/PyUnusedParameterInspection.html @@ -0,0 +1,7 @@ + + +

+ Reports parameters of a function that are not used in its body. +

+ + diff --git a/python/python-psi-impl/resources/inspectionDescriptions/metaInformation.json b/python/python-psi-impl/resources/inspectionDescriptions/metaInformation.json index 40c87e92dd9b..8eaa1a262b6a 100644 --- a/python/python-psi-impl/resources/inspectionDescriptions/metaInformation.json +++ b/python/python-psi-impl/resources/inspectionDescriptions/metaInformation.json @@ -236,6 +236,14 @@ "id": "PyUnusedLocalInspection", "codeQualityCategory": "Code Style" }, + { + "id": "PyUnusedParameterInspection", + "codeQualityCategory": "Code Style" + }, + { + "id": "PyUnusedFunctionInspection", + "codeQualityCategory": "Code Style" + }, { "id": "PyMethodFirstArgAssignmentInspection", "codeQualityCategory": "Reliability" 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 c094294a396c..6c681b6524fb 100644 --- a/python/python-psi-impl/resources/intellij.python.psi.impl.xml +++ b/python/python-psi-impl/resources/intellij.python.psi.impl.xml @@ -199,6 +199,10 @@ + + + + diff --git a/python/python-psi-impl/resources/messages/PyPsiBundle.properties b/python/python-psi-impl/resources/messages/PyPsiBundle.properties index 5407d8a4eb82..d144549c9c25 100644 --- a/python/python-psi-impl/resources/messages/PyPsiBundle.properties +++ b/python/python-psi-impl/resources/messages/PyPsiBundle.properties @@ -880,6 +880,8 @@ INSP.mock.patch.too.many.params=Function has {0} extra parameter(s) not matched # PyUnusedLocalInspection INSP.NAME.unused=Unused local symbols +INSP.NAME.unused.parameter=Unused parameter +INSP.NAME.unused.function=Unused function INSP.unused.locals.parameter.isnot.used=Parameter `{0}` value is not used INSP.unused.locals.local.variable.isnot.used=Local variable `{0}` value is not used INSP.unused.locals.replace.with.wildcard=Replace with _ diff --git a/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedFunctionInspection.kt b/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedFunctionInspection.kt new file mode 100644 index 000000000000..11a7ee55784a --- /dev/null +++ b/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedFunctionInspection.kt @@ -0,0 +1,11 @@ +// 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.unusedLocal + +/** + * Reports unused local functions. Unused local variables and other local symbols are reported separately by + * [PyUnusedLocalInspection]; both inspections share [PyUnusedLocalInspectionVisitor] for the analysis. + */ +class PyUnusedFunctionInspection : PyUnusedSymbolInspection() { + override val reportTarget: PyUnusedLocalInspectionVisitor.ReportTarget + get() = PyUnusedLocalInspectionVisitor.ReportTarget.FUNCTIONS +} diff --git a/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedLocalInspection.java b/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedLocalInspection.java index 9a98c6138a5a..86e73dafd185 100644 --- a/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedLocalInspection.java +++ b/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedLocalInspection.java @@ -19,7 +19,6 @@ public final class PyUnusedLocalInspection extends PyInspection { private static final Key KEY = Key.create("PyUnusedLocal.Visitor"); public boolean ignoreTupleUnpacking = true; - public boolean ignoreLambdaParameters = true; public boolean ignoreLoopIterationVariables = true; public boolean ignoreVariablesStartingWithUnderscore = true; @@ -29,9 +28,11 @@ public final class PyUnusedLocalInspection extends PyInspection { @NotNull LocalInspectionToolSession session) { final PyUnusedLocalInspectionVisitor visitor = new PyUnusedLocalInspectionVisitor(holder, ignoreTupleUnpacking, - ignoreLambdaParameters, + true, ignoreLoopIterationVariables, ignoreVariablesStartingWithUnderscore, + PyUnusedLocalInspectionVisitor.ReportTarget.LOCALS, + PyUnusedLocalInspectionVisitor.getSharedAnalysis(session), PyInspectionVisitor.getContext(session)); // buildVisitor() will be called on injected files in the same session - don't overwrite if we already have one final PyUnusedLocalInspectionVisitor existingVisitor = session.getUserData(KEY); @@ -54,7 +55,6 @@ public final class PyUnusedLocalInspection extends PyInspection { public @NotNull OptPane getOptionsPane() { return pane( checkbox("ignoreTupleUnpacking", PyPsiBundle.message("INSP.unused.locals.ignore.variables.used.in.tuple.unpacking")), - checkbox("ignoreLambdaParameters", PyPsiBundle.message("INSP.unused.locals.ignore.lambda.parameters")), checkbox("ignoreLoopIterationVariables", PyPsiBundle.message("INSP.unused.locals.ignore.range.iteration.variables")), checkbox("ignoreVariablesStartingWithUnderscore", PyPsiBundle.message("INSP.unused.locals.ignore.variables.starting.with")) ); diff --git a/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedLocalInspectionVisitor.java b/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedLocalInspectionVisitor.java index 145cb1a4f098..889b9d897c68 100644 --- a/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedLocalInspectionVisitor.java +++ b/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedLocalInspectionVisitor.java @@ -3,6 +3,7 @@ package com.jetbrains.python.inspections.unusedLocal; import com.intellij.codeInsight.controlflow.ControlFlowUtil; import com.intellij.codeInsight.controlflow.Instruction; +import com.intellij.codeInspection.LocalInspectionToolSession; import com.intellij.codeInspection.LocalQuickFix; import com.intellij.codeInspection.ProblemHighlightType; import com.intellij.codeInspection.ProblemsHolder; @@ -10,6 +11,7 @@ import com.intellij.lang.injection.InjectedLanguageManager; import com.intellij.modcommand.ModPsiUpdater; import com.intellij.modcommand.PsiUpdateModCommandQuickFix; import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.Key; import com.intellij.openapi.util.Pair; import com.intellij.openapi.util.TextRange; import com.intellij.openapi.util.text.StringUtil; @@ -102,23 +104,64 @@ public final class PyUnusedLocalInspectionVisitor extends PyInspectionVisitor { private final boolean myIgnoreLambdaParameters; private final boolean myIgnoreRangeIterationVariables; private final boolean myIgnoreVariablesStartingWithUnderscore; + private final ReportTarget myReportTarget; + private final SharedAnalysis mySharedAnalysis; + + /** Which kind of unused element this visitor reports; each kind is exposed as a separate inspection. */ + public enum ReportTarget { LOCALS, PARAMETERS, FUNCTIONS } + + /** + * Analysis state shared by the unused-locals, unused-parameter, and unused-function inspections. All three perform the same + * control-flow analysis but report different element kinds; sharing this state through the {@link LocalInspectionToolSession} + * keeps the heavy analysis running once per file instead of once per inspection. + */ + public static final class SharedAnalysis { + // Names defined directly in a scope. They all belong to this scope. + private final Map> scopeWrites = new ConcurrentHashMap<>(); + // Names read directly in a scope. They might belong to some outer scope. + private final Map> scopeReads = new ConcurrentHashMap<>(); + private volatile Set unusedElements; + } + + private static final Key SHARED_ANALYSIS_KEY = Key.create("PyUnusedLocal.SharedAnalysis"); + + /** Returns the analysis state shared by the unused-symbol inspections running on this session, creating it on first use. */ + public static @NotNull SharedAnalysis getSharedAnalysis(@NotNull LocalInspectionToolSession session) { + SharedAnalysis analysis = session.getUserData(SHARED_ANALYSIS_KEY); + if (analysis == null) { + synchronized (SHARED_ANALYSIS_KEY) { + analysis = session.getUserData(SHARED_ANALYSIS_KEY); + if (analysis == null) { + analysis = new SharedAnalysis(); + session.putUserData(SHARED_ANALYSIS_KEY, analysis); + } + } + } + return analysis; + } // Names defined directly in a scope. They all belong to this scope. - private final Map> myScopeWrites = new ConcurrentHashMap<>(); + private final Map> myScopeWrites; // Names read directly in a scope. They might belong to some outer scope. - private final Map> myScopeReads = new ConcurrentHashMap<>(); + private final Map> myScopeReads; public PyUnusedLocalInspectionVisitor(@NotNull ProblemsHolder holder, boolean ignoreTupleUnpacking, boolean ignoreLambdaParameters, boolean ignoreRangeIterationVariables, boolean ignoreVariablesStartingWithUnderscore, + @NotNull ReportTarget reportTarget, + @NotNull SharedAnalysis sharedAnalysis, @NotNull TypeEvalContext context) { super(holder, context); myIgnoreTupleUnpacking = ignoreTupleUnpacking; myIgnoreLambdaParameters = ignoreLambdaParameters; myIgnoreRangeIterationVariables = ignoreRangeIterationVariables; myIgnoreVariablesStartingWithUnderscore = ignoreVariablesStartingWithUnderscore; + myReportTarget = reportTarget; + mySharedAnalysis = sharedAnalysis; + myScopeWrites = sharedAnalysis.scopeWrites; + myScopeReads = sharedAnalysis.scopeReads; } @Override @@ -150,6 +193,10 @@ public final class PyUnusedLocalInspectionVisitor extends PyInspectionVisitor { } private void collectAllWrites(ScopeOwner owner) { + if (myScopeWrites.containsKey(owner)) { + // Already computed for another unused-symbol inspection sharing this session. + return; + } Set scopeWrites = new HashSet<>(); // type parameter list is not included in CFG if (owner instanceof PyTypeParameterListOwner typeParameterListOwner) { @@ -379,6 +426,16 @@ public final class PyUnusedLocalInspectionVisitor extends PyInspectionVisitor { return false; } + private static @NotNull ReportTarget categorize(@NotNull PsiElement element) { + if (element instanceof PyNamedParameter || element.getParent() instanceof PyNamedParameter) { + return ReportTarget.PARAMETERS; + } + if (element instanceof PyFunction) { + return ReportTarget.FUNCTIONS; + } + return ReportTarget.LOCALS; + } + public void registerProblems() { final List filters = PyInspectionExtension.EP_NAME.getExtensionList(); // Register problems @@ -386,11 +443,21 @@ public final class PyUnusedLocalInspectionVisitor extends PyInspectionVisitor { final Set functionsWithInheritors = new HashSet<>(); final Map emptyFunctions = new HashMap<>(); - Set unusedElements = StreamEx.of(myScopeWrites.entrySet()) - .flatCollection(writeEntry -> ContainerUtil.subtract(writeEntry.getValue(), getReadsInsideScope(writeEntry.getKey()))) - .toImmutableSet(); + Set unusedElements = mySharedAnalysis.unusedElements; + if (unusedElements == null) { + unusedElements = StreamEx.of(myScopeWrites.entrySet()) + .flatCollection(writeEntry -> ContainerUtil.subtract(writeEntry.getValue(), getReadsInsideScope(writeEntry.getKey()))) + .toImmutableSet(); + // Benign race: concurrent inspections may recompute the identical set; the last write wins. + mySharedAnalysis.unusedElements = unusedElements; + } for (PsiElement element : unusedElements) { + // Parameters, functions, and the remaining "local symbols" are each reported by a separate inspection. + if (categorize(element) != myReportTarget) { + continue; + } + boolean ignoreUnused = false; for (PyInspectionExtension filter : filters) { if (filter.ignoreUnused(element, myTypeEvalContext)) { diff --git a/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedParameterInspection.kt b/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedParameterInspection.kt new file mode 100644 index 000000000000..5612d5bf63ae --- /dev/null +++ b/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedParameterInspection.kt @@ -0,0 +1,24 @@ +// 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.unusedLocal + +import com.intellij.codeInspection.options.OptPane +import com.intellij.codeInspection.options.OptPane.pane +import com.jetbrains.python.PyPsiBundle + +/** + * Reports unused function parameters. Unused local variables and other local symbols are reported separately by + * [PyUnusedLocalInspection]; both inspections share [PyUnusedLocalInspectionVisitor] for the analysis. + */ +class PyUnusedParameterInspection : PyUnusedSymbolInspection() { + @JvmField + var ignoreLambdaParameters: Boolean = true + + override val reportTarget: PyUnusedLocalInspectionVisitor.ReportTarget + get() = PyUnusedLocalInspectionVisitor.ReportTarget.PARAMETERS + + override fun shouldIgnoreLambdaParameters(): Boolean = ignoreLambdaParameters + + override fun getOptionsPane(): OptPane = pane( + OptPane.checkbox("ignoreLambdaParameters", PyPsiBundle.message("INSP.unused.locals.ignore.lambda.parameters")), + ) +} diff --git a/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedSymbolInspection.kt b/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedSymbolInspection.kt new file mode 100644 index 000000000000..4ae9ed2e0a43 --- /dev/null +++ b/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedSymbolInspection.kt @@ -0,0 +1,44 @@ +// 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.unusedLocal + +import com.intellij.codeInspection.LocalInspectionToolSession +import com.intellij.codeInspection.ProblemsHolder +import com.intellij.openapi.application.runReadActionBlocking +import com.intellij.openapi.util.Key +import com.intellij.psi.PsiElementVisitor +import com.jetbrains.python.inspections.PyInspection +import com.jetbrains.python.inspections.PyInspectionVisitor + +/** + * Base class for the unused-parameter and unused-function inspections. Both report a single kind of unused symbol via the shared + * [PyUnusedLocalInspectionVisitor]; the actual control-flow analysis is shared across all unused-symbol inspections running on the + * same session (see [PyUnusedLocalInspectionVisitor.SharedAnalysis]). Unused local variables and other local symbols are reported + * separately by [PyUnusedLocalInspection]. + */ +abstract class PyUnusedSymbolInspection : PyInspection() { + private val visitorKey = Key.create("${javaClass.simpleName}.Visitor") + + /** The single element kind this inspection reports. */ + protected abstract val reportTarget: PyUnusedLocalInspectionVisitor.ReportTarget + + /** Whether lambda parameters should be ignored; only meaningful when [reportTarget] is `PARAMETERS`. */ + protected open fun shouldIgnoreLambdaParameters(): Boolean = true + + final override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean, session: LocalInspectionToolSession): PsiElementVisitor { + val visitor = PyUnusedLocalInspectionVisitor(holder, false, shouldIgnoreLambdaParameters(), false, false, + reportTarget, + PyUnusedLocalInspectionVisitor.getSharedAnalysis(session), + PyInspectionVisitor.getContext(session)) + // buildVisitor() will be called on injected files in the same session - don't overwrite if we already have one + if (session.getUserData(visitorKey) == null) { + session.putUserData(visitorKey, visitor) + } + return visitor + } + + final override fun inspectionFinished(session: LocalInspectionToolSession, holder: ProblemsHolder) { + val visitor = session.getUserData(visitorKey) ?: return + runReadActionBlocking { visitor.registerProblems() } + session.putUserData(visitorKey, null) + } +} diff --git a/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedSymbolInspectionMerger.kt b/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedSymbolInspectionMerger.kt new file mode 100644 index 000000000000..b891736200a6 --- /dev/null +++ b/python/python-psi-impl/src/com/jetbrains/python/inspections/unusedLocal/PyUnusedSymbolInspectionMerger.kt @@ -0,0 +1,59 @@ +// 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.unusedLocal + +import com.intellij.codeInspection.ex.InspectionElementsMergerBase +import org.jdom.Element + +/** + * Migrates user inspection-profile settings (enabled state, severity, and shared options such as `ignoreLambdaParameters`) from + * the former combined `PyUnusedLocalInspection` to the inspection that took over part of its reporting after PY-9687 split it into + * separate unused-local / unused-parameter / unused-function inspections. Without this, a user who had disabled or reconfigured + * `PyUnusedLocalInspection` would silently get the new inspections back at their defaults. + * + * Suppression is intentionally NOT inherited: `# noinspection PyUnusedLocal` no longer silences parameters or functions (the split + * is a deliberate clean break), so [getSuppressIds] returns only this inspection's own id instead of letting the base fall back to + * the source tool name. + */ +abstract class PyUnusedSymbolInspectionMerger : InspectionElementsMergerBase() { + /** Suppress id of the merged (new) inspection. */ + protected abstract val mergedSuppressId: String + + final override fun getSourceToolNames(): Array = arrayOf(SOURCE_TOOL_NAME) + + final override fun getSuppressIds(): Array = arrayOf(mergedSuppressId) + + final override fun merge(inspectionElements: Map): Element? { + val merged = super.merge(inspectionElements) + if (merged != null) return merged + // super.merge() produces nothing when the source node carried no option children (e.g. a plain enable/disable toggle or a + // severity change). Still carry over the enabled state and severity so the split stays transparent to such users. + val source = inspectionElements[SOURCE_TOOL_NAME] ?: return null + return Element(INSPECTION_TOOL_TAG).apply { + setAttribute(CLASS_ATTR, mergedToolName) + source.getAttributeValue(ENABLED_ATTR)?.let { setAttribute(ENABLED_ATTR, it) } + source.getAttributeValue(ENABLED_BY_DEFAULT_ATTR)?.let { setAttribute(ENABLED_BY_DEFAULT_ATTR, it) } + source.getAttributeValue(LEVEL_ATTR)?.let { setAttribute(LEVEL_ATTR, it) } + } + } + + private companion object { + const val SOURCE_TOOL_NAME = "PyUnusedLocalInspection" + + // Inspection-profile serialization names (mirror InspectionProfileImpl/ToolsImpl, which are not visible from here). + const val INSPECTION_TOOL_TAG = "inspection_tool" + const val CLASS_ATTR = "class" + const val ENABLED_ATTR = "enabled" + const val ENABLED_BY_DEFAULT_ATTR = "enabled_by_default" + const val LEVEL_ATTR = "level" + } +} + +class PyUnusedParameterInspectionMerger : PyUnusedSymbolInspectionMerger() { + override fun getMergedToolName(): String = "PyUnusedParameterInspection" + override val mergedSuppressId: String get() = "unused-parameter" +} + +class PyUnusedFunctionInspectionMerger : PyUnusedSymbolInspectionMerger() { + override fun getMergedToolName(): String = "PyUnusedFunctionInspection" + override val mergedSuppressId: String get() = "unused-function" +} diff --git a/python/src/com/jetbrains/python/console/ConsoleVisitorFilter.java b/python/src/com/jetbrains/python/console/ConsoleVisitorFilter.java index f61664202c74..db7e0badd2c9 100644 --- a/python/src/com/jetbrains/python/console/ConsoleVisitorFilter.java +++ b/python/src/com/jetbrains/python/console/ConsoleVisitorFilter.java @@ -10,7 +10,9 @@ import com.jetbrains.python.inspections.PyPep8Inspection; import com.jetbrains.python.inspections.PySingleQuotedDocstringInspection; import com.jetbrains.python.inspections.PyStatementEffectInspection; import com.jetbrains.python.inspections.PyUnboundLocalVariableInspection; +import com.jetbrains.python.inspections.unusedLocal.PyUnusedFunctionInspection; import com.jetbrains.python.inspections.unusedLocal.PyUnusedLocalInspection; +import com.jetbrains.python.inspections.unusedLocal.PyUnusedParameterInspection; import com.jetbrains.python.psi.PythonVisitorFilter; import com.jetbrains.python.validation.PyDocStringHighlightingAnnotator; import org.jetbrains.annotations.NotNull; @@ -26,7 +28,8 @@ final class ConsoleVisitorFilter implements PythonVisitorFilter { //if we're in console if (PydevConsoleRunnerUtil.isInPydevConsole(file)) { //inspections - if (visitorClass == PyUnusedLocalInspection.class || visitorClass == PyUnboundLocalVariableInspection.class || + if (visitorClass == PyUnusedLocalInspection.class || visitorClass == PyUnusedParameterInspection.class || + visitorClass == PyUnusedFunctionInspection.class || visitorClass == PyUnboundLocalVariableInspection.class || visitorClass == PyStatementEffectInspection.class || visitorClass == PySingleQuotedDocstringInspection.class || visitorClass == PyIncorrectDocstringInspection.class || visitorClass == PyMissingOrEmptyDocstringInspection.class || visitorClass == PyMandatoryEncodingInspection.class || visitorClass == PyPep8Inspection.class || diff --git a/python/src/com/jetbrains/python/pyi/PyiVisitorFilter.java b/python/src/com/jetbrains/python/pyi/PyiVisitorFilter.java index 45ede5636cc8..bb273887c378 100644 --- a/python/src/com/jetbrains/python/pyi/PyiVisitorFilter.java +++ b/python/src/com/jetbrains/python/pyi/PyiVisitorFilter.java @@ -10,7 +10,9 @@ import com.jetbrains.python.inspections.PyPropertyDefinitionInspection; import com.jetbrains.python.inspections.PyShadowingBuiltinsInspection; import com.jetbrains.python.inspections.PyStatementEffectInspection; import com.jetbrains.python.inspections.PyTypeCheckerInspection; +import com.jetbrains.python.inspections.unusedLocal.PyUnusedFunctionInspection; import com.jetbrains.python.inspections.unusedLocal.PyUnusedLocalInspection; +import com.jetbrains.python.inspections.unusedLocal.PyUnusedParameterInspection; import com.jetbrains.python.psi.PythonVisitorFilter; import org.jetbrains.annotations.NotNull; @@ -20,6 +22,8 @@ public final class PyiVisitorFilter implements PythonVisitorFilter { private static final @NotNull Set> disabledVisitors = ImmutableSet.of( PyUnusedLocalInspection.class, + PyUnusedParameterInspection.class, + PyUnusedFunctionInspection.class, PyStatementEffectInspection.class, PyCompatibilityInspection.class, PyMissingOrEmptyDocstringInspection.class, diff --git a/python/testSrc/com/jetbrains/python/inspections/PyUnusedFunctionInspectionTest.kt b/python/testSrc/com/jetbrains/python/inspections/PyUnusedFunctionInspectionTest.kt new file mode 100644 index 000000000000..bef8089e29e9 --- /dev/null +++ b/python/testSrc/com/jetbrains/python/inspections/PyUnusedFunctionInspectionTest.kt @@ -0,0 +1,59 @@ +// 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.intellij.lang.annotation.HighlightSeverity +import com.intellij.idea.TestFor +import com.jetbrains.python.PythonFileType +import com.jetbrains.python.fixtures.PyCodeInsightTestCase +import com.jetbrains.python.inspections.unusedLocal.PyUnusedFunctionInspection +import org.intellij.lang.annotations.Language +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test + +@TestFor(issues = ["PY-9687"]) +class PyUnusedFunctionInspectionTest : PyCodeInsightTestCase() { + + /** Asserts the exact set of unused-function warnings (descriptions) reported for [code]. */ + private fun doTest(@Language("Python") code: String, vararg expectedMessages: String) { + myFixture.configureByText(PythonFileType.INSTANCE, code.trimIndent()) + myFixture.enableInspections(PyUnusedFunctionInspection()) + val actual = myFixture.doHighlighting() + .filter { it.severity == HighlightSeverity.WEAK_WARNING } + .map { it.description } + .sorted() + Assertions.assertEquals(expectedMessages.sorted(), actual) + } + + @Test + fun `unused local function is reported`() { + // The unused local variable 'a' belongs to PyUnusedLocal and is not enabled here. + doTest(""" + def outer(): + a = 1 + def inner(): + return 1 + return a + """, "Local function 'inner' is not used") + } + + @Test + fun `used local function is not reported`() { + doTest(""" + def outer(): + def inner(): + return 1 + return inner() + """) + } + + @Test + fun `noinspection unused-function suppresses the function`() { + doTest(""" + def outer(): + # noinspection unused-function + def inner(): + return 1 + return 0 + """) + } +} diff --git a/python/testSrc/com/jetbrains/python/inspections/PyUnusedLocalInspectionTest.java b/python/testSrc/com/jetbrains/python/inspections/PyUnusedLocalInspectionTest.java index e869e47c6b8b..eec8e55f003c 100644 --- a/python/testSrc/com/jetbrains/python/inspections/PyUnusedLocalInspectionTest.java +++ b/python/testSrc/com/jetbrains/python/inspections/PyUnusedLocalInspectionTest.java @@ -15,19 +15,25 @@ */ package com.jetbrains.python.inspections; +import com.intellij.codeInspection.LocalInspectionTool; import com.intellij.idea.TestFor; import com.jetbrains.python.fixtures.PyInspectionTestCase; +import com.jetbrains.python.inspections.unusedLocal.PyUnusedFunctionInspection; import com.jetbrains.python.inspections.unusedLocal.PyUnusedLocalInspection; +import com.jetbrains.python.inspections.unusedLocal.PyUnusedParameterInspection; import com.jetbrains.python.psi.LanguageLevel; import org.jetbrains.annotations.NotNull; +import java.util.List; + public class PyUnusedLocalInspectionTest extends PyInspectionTestCase { public void testPy2() { final PyUnusedLocalInspection inspection = new PyUnusedLocalInspection(); inspection.ignoreTupleUnpacking = false; - inspection.ignoreLambdaParameters = false; - runWithLanguageLevel(LanguageLevel.PYTHON27, () -> doTest(inspection)); + final PyUnusedParameterInspection parameterInspection = new PyUnusedParameterInspection(); + parameterInspection.ignoreLambdaParameters = false; + runWithLanguageLevel(LanguageLevel.PYTHON27, () -> doTest(inspection, parameterInspection)); } public void testNonlocal() { @@ -149,7 +155,6 @@ public class PyUnusedLocalInspectionTest extends PyInspectionTestCase { public void testIgnoringVariablesStartingWithUnderscore() { final PyUnusedLocalInspection inspection = new PyUnusedLocalInspection(); inspection.ignoreVariablesStartingWithUnderscore = true; - inspection.ignoreLambdaParameters = false; inspection.ignoreLoopIterationVariables = false; inspection.ignoreTupleUnpacking = false; doTest(inspection); @@ -310,10 +315,21 @@ def test(): return PyUnusedLocalInspection.class; } + // Parameter and function reporting now live in separate inspections; enable them too so the shared testData keeps matching. + @NotNull + @Override + protected List> getAdditionalInspectionClasses() { + return List.of(PyUnusedParameterInspection.class, PyUnusedFunctionInspection.class); + } + private void doTest(@NotNull PyUnusedLocalInspection inspection) { + doTest(inspection, new PyUnusedParameterInspection()); + } + + private void doTest(@NotNull PyUnusedLocalInspection inspection, @NotNull PyUnusedParameterInspection parameterInspection) { final String path = "inspections/PyUnusedLocalInspection/" + getTestName(true) + ".py"; myFixture.configureByFile(path); - myFixture.enableInspections(inspection); + myFixture.enableInspections(inspection, parameterInspection, new PyUnusedFunctionInspection()); myFixture.checkHighlighting(true, false, true); } } diff --git a/python/testSrc/com/jetbrains/python/inspections/PyUnusedParameterInspectionTest.kt b/python/testSrc/com/jetbrains/python/inspections/PyUnusedParameterInspectionTest.kt new file mode 100644 index 000000000000..a604676a3438 --- /dev/null +++ b/python/testSrc/com/jetbrains/python/inspections/PyUnusedParameterInspectionTest.kt @@ -0,0 +1,71 @@ +// 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.intellij.lang.annotation.HighlightSeverity +import com.intellij.idea.TestFor +import com.jetbrains.python.PythonFileType +import com.jetbrains.python.fixtures.PyCodeInsightTestCase +import com.jetbrains.python.inspections.unusedLocal.PyUnusedParameterInspection +import org.intellij.lang.annotations.Language +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test + +@TestFor(issues = ["PY-9687"]) +class PyUnusedParameterInspectionTest : PyCodeInsightTestCase() { + + /** Asserts the exact set of unused-parameter warnings (descriptions) reported for [code]. */ + private fun doTest(@Language("Python") code: String, vararg expectedMessages: String) { + myFixture.configureByText(PythonFileType.INSTANCE, code.trimIndent()) + myFixture.enableInspections(PyUnusedParameterInspection()) + val actual = myFixture.doHighlighting() + .filter { it.severity == HighlightSeverity.WEAK_WARNING } + .map { it.description } + .sorted() + Assertions.assertEquals(expectedMessages.sorted(), actual) + } + + @Test + fun `unused parameter is reported`() { + // The unused local 'a' belongs to PyUnusedLocal and is not enabled here. + doTest(""" + def f(x): + a = 1 + """, "Parameter 'x' value is not used") + } + + @Test + fun `self is not reported but a regular parameter is`() { + doTest(""" + class A: + def m(self, x): + return 1 + """, "Parameter 'x' value is not used") + } + + @Test + fun `lambda parameter is not reported`() { + doTest(""" + f = lambda x: 1 + print(f) + """) + } + + @Test + fun `noinspection unused-parameter suppresses the parameter`() { + doTest(""" + # noinspection unused-parameter + def f(x): + return 1 + """) + } + + @Test + fun `legacy PyUnusedLocal does not suppress the parameter`() { + // Clean break: PyUnusedLocal no longer silences parameters. + doTest(""" + # noinspection PyUnusedLocal + def f(x): + return 1 + """, "Parameter 'x' value is not used") + } +} diff --git a/python/testSrc/com/jetbrains/python/inspections/PyUnusedSymbolInspectionMergerTest.kt b/python/testSrc/com/jetbrains/python/inspections/PyUnusedSymbolInspectionMergerTest.kt new file mode 100644 index 000000000000..4191484fa0f3 --- /dev/null +++ b/python/testSrc/com/jetbrains/python/inspections/PyUnusedSymbolInspectionMergerTest.kt @@ -0,0 +1,57 @@ +// 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.intellij.codeInspection.ex.InspectionProfileImpl +import com.intellij.codeInspection.ex.InspectionToolRegistrar +import com.intellij.idea.TestFor +import com.intellij.openapi.util.JDOMUtil +import com.jetbrains.python.fixtures.PyTestCase +import com.jetbrains.python.inspections.unusedLocal.PyUnusedParameterInspection + +/** + * Verifies that profile settings from the former combined `PyUnusedLocalInspection` migrate to the inspections that PY-9687 split + * it into, so users who had disabled or reconfigured it are not silently reset to defaults. + */ +@TestFor(issues = ["PY-9687"]) +class PyUnusedSymbolInspectionMergerTest : PyTestCase() { + private lateinit var profile: InspectionProfileImpl + + override fun setUp() { + super.setUp() + InspectionProfileImpl.INIT_INSPECTIONS = true + profile = InspectionProfileImpl("Test", InspectionToolRegistrar.getInstance(), InspectionProfileImpl("base")) + } + + override fun tearDown() { + try { + InspectionProfileImpl.INIT_INSPECTIONS = false + } + finally { + super.tearDown() + } + } + + // A user who disabled the former combined inspection should not get the split-out inspections back, enabled, at their defaults. + fun testDisabledStateMigratesToSplitInspections() { + profile.readExternal(JDOMUtil.load(""" + + """)) + assertFalse(profile.getToolsOrNull("PyUnusedParameterInspection", null)!!.isEnabled) + assertFalse(profile.getToolsOrNull("PyUnusedFunctionInspection", null)!!.isEnabled) + } + + // The ignoreLambdaParameters option used to live on PyUnusedLocalInspection; it must carry over to the parameter inspection. + fun testIgnoreLambdaParametersOptionMigrates() { + profile.readExternal(JDOMUtil.load(""" + + """)) + val tool = profile.getToolsOrNull("PyUnusedParameterInspection", null)!!.tool.tool as PyUnusedParameterInspection + assertFalse(tool.ignoreLambdaParameters) + } +} diff --git a/python/testSrc/com/jetbrains/python/pyi/PyiInspectionsTest.java b/python/testSrc/com/jetbrains/python/pyi/PyiInspectionsTest.java index f6b0b4728b77..ab30fa354ec8 100644 --- a/python/testSrc/com/jetbrains/python/pyi/PyiInspectionsTest.java +++ b/python/testSrc/com/jetbrains/python/pyi/PyiInspectionsTest.java @@ -33,7 +33,7 @@ import com.jetbrains.python.inspections.PyTypeCheckerInspection; import com.jetbrains.python.inspections.PyUnboundLocalVariableInspection; import com.jetbrains.python.inspections.PyUnusedImportsInspection; import com.jetbrains.python.inspections.unresolvedReference.PyUnresolvedReferencesInspection; -import com.jetbrains.python.inspections.unusedLocal.PyUnusedLocalInspection; +import com.jetbrains.python.inspections.unusedLocal.PyUnusedParameterInspection; import com.jetbrains.python.psi.PythonVisitorFilter; import org.jetbrains.annotations.NotNull; @@ -110,7 +110,7 @@ public class PyiInspectionsTest extends PyTestCase { } public void testPyiUnusedParameters() { - doPyiTest(PyUnusedLocalInspection.class); + doPyiTest(PyUnusedParameterInspection.class); } public void testPyiStatementEffect() { diff --git a/python/testSrc/com/jetbrains/python/quickFixes/PyAddFieldQuickFixTest.java b/python/testSrc/com/jetbrains/python/quickFixes/PyAddFieldQuickFixTest.java index eea517a8867a..293067c3899f 100644 --- a/python/testSrc/com/jetbrains/python/quickFixes/PyAddFieldQuickFixTest.java +++ b/python/testSrc/com/jetbrains/python/quickFixes/PyAddFieldQuickFixTest.java @@ -21,7 +21,7 @@ import com.jetbrains.python.PyQuickFixTestCase; import com.jetbrains.python.allure.Layers; import com.jetbrains.python.allure.Subsystems; import com.jetbrains.python.inspections.unresolvedReference.PyUnresolvedReferencesInspection; -import com.jetbrains.python.inspections.unusedLocal.PyUnusedLocalInspection; +import com.jetbrains.python.inspections.unusedLocal.PyUnusedParameterInspection; import com.jetbrains.python.psi.LanguageLevel; @TestDataPath("$CONTENT_ROOT/../testData//quickFixes/AddFieldQuickFixTest/") @@ -52,16 +52,16 @@ public class PyAddFieldQuickFixTest extends PyQuickFixTestCase { } public void testFromUnusedParameter() { // PY-1398 - doQuickFixTest(PyUnusedLocalInspection.class, "Add field 'foo' to class A"); + doQuickFixTest(PyUnusedParameterInspection.class, "Add field 'foo' to class A"); } // PY-14733 public void testAddFieldInitializationInsideEmptyInit() { - doQuickFixTest(PyUnusedLocalInspection.class, "Add field 'foo' to class A"); + doQuickFixTest(PyUnusedParameterInspection.class, "Add field 'foo' to class A"); } public void testFromUnusedParameterKeyword() { // PY-1602 - doQuickFixTest(PyUnusedLocalInspection.class, "Add field 'foo' to class A"); + doQuickFixTest(PyUnusedParameterInspection.class, "Add field 'foo' to class A"); } // PY-21284 diff --git a/python/testSrc/com/jetbrains/python/quickFixes/PyRemoveParameterQuickFixTest.java b/python/testSrc/com/jetbrains/python/quickFixes/PyRemoveParameterQuickFixTest.java index 735f7d2c8a37..3509ec9ca049 100644 --- a/python/testSrc/com/jetbrains/python/quickFixes/PyRemoveParameterQuickFixTest.java +++ b/python/testSrc/com/jetbrains/python/quickFixes/PyRemoveParameterQuickFixTest.java @@ -8,7 +8,7 @@ import com.jetbrains.python.PyQuickFixTestCase; import com.jetbrains.python.allure.Layers; import com.jetbrains.python.allure.Subsystems; import com.jetbrains.python.documentation.docstrings.DocStringFormat; -import com.jetbrains.python.inspections.unusedLocal.PyUnusedLocalInspection; +import com.jetbrains.python.inspections.unusedLocal.PyUnusedParameterInspection; import com.jetbrains.python.psi.LanguageLevel; @TestDataPath("$CONTENT_ROOT/../testData//quickFixes/PyRemoveParameterQuickFixTest/") @@ -17,26 +17,26 @@ import com.jetbrains.python.psi.LanguageLevel; public class PyRemoveParameterQuickFixTest extends PyQuickFixTestCase { public void testParam() { - doQuickFixTest(PyUnusedLocalInspection.class, PyPsiBundle.message("QFIX.NAME.remove.parameter")); + doQuickFixTest(PyUnusedParameterInspection.class, PyPsiBundle.message("QFIX.NAME.remove.parameter")); } public void testKwParam() { - doQuickFixTest(PyUnusedLocalInspection.class, PyPsiBundle.message("QFIX.NAME.remove.parameter")); + doQuickFixTest(PyUnusedParameterInspection.class, PyPsiBundle.message("QFIX.NAME.remove.parameter")); } public void testDocstring() { runWithDocStringFormat(DocStringFormat.REST, () -> - doQuickFixTest(PyUnusedLocalInspection.class, PyPsiBundle.message("QFIX.NAME.remove.parameter")) + doQuickFixTest(PyUnusedParameterInspection.class, PyPsiBundle.message("QFIX.NAME.remove.parameter")) ); } public void testUsage() { - doQuickFixTest(PyUnusedLocalInspection.class, PyPsiBundle.message("QFIX.NAME.remove.parameter")); + doQuickFixTest(PyUnusedParameterInspection.class, PyPsiBundle.message("QFIX.NAME.remove.parameter")); } public void testSingleStarTwoParam() { runWithDocStringFormat(DocStringFormat.REST, () -> - doQuickFixTest(PyUnusedLocalInspection.class, PyPsiBundle.message("QFIX.NAME.remove.parameter"), LanguageLevel.PYTHON34) + doQuickFixTest(PyUnusedParameterInspection.class, PyPsiBundle.message("QFIX.NAME.remove.parameter"), LanguageLevel.PYTHON34) ); } @@ -45,7 +45,7 @@ public class PyRemoveParameterQuickFixTest extends PyQuickFixTestCase { LanguageLevel.PYTHON34, () -> { final String testFileName = getTestName(true); - myFixture.enableInspections(PyUnusedLocalInspection.class); + myFixture.enableInspections(PyUnusedParameterInspection.class); myFixture.configureByFile(testFileName + ".py"); myFixture.checkHighlighting(true, false, false); final IntentionAction intentionAction = myFixture.getAvailableIntention(PyPsiBundle.message("QFIX.NAME.remove.parameter")); @@ -56,11 +56,11 @@ public class PyRemoveParameterQuickFixTest extends PyQuickFixTestCase { // PY-22971 public void testTopLevelOverloadsAndImplementation() { - doQuickFixTest(PyUnusedLocalInspection.class, PyPsiBundle.message("QFIX.NAME.remove.parameter"), LanguageLevel.PYTHON35); + doQuickFixTest(PyUnusedParameterInspection.class, PyPsiBundle.message("QFIX.NAME.remove.parameter"), LanguageLevel.PYTHON35); } // PY-22971 public void testOverloadsAndImplementationInClass() { - doQuickFixTest(PyUnusedLocalInspection.class, PyPsiBundle.message("QFIX.NAME.remove.parameter"), LanguageLevel.PYTHON35); + doQuickFixTest(PyUnusedParameterInspection.class, PyPsiBundle.message("QFIX.NAME.remove.parameter"), LanguageLevel.PYTHON35); } } diff --git a/python/testSrc/com/jetbrains/python/quickFixes/PyRemoveStatementQuickFixTest.java b/python/testSrc/com/jetbrains/python/quickFixes/PyRemoveStatementQuickFixTest.java index c15b23758bdc..7f2e8084a83a 100644 --- a/python/testSrc/com/jetbrains/python/quickFixes/PyRemoveStatementQuickFixTest.java +++ b/python/testSrc/com/jetbrains/python/quickFixes/PyRemoveStatementQuickFixTest.java @@ -21,6 +21,7 @@ import com.jetbrains.python.PyQuickFixTestCase; import com.jetbrains.python.allure.Layers; import com.jetbrains.python.allure.Subsystems; import com.jetbrains.python.inspections.PyReturnFromInitInspection; +import com.jetbrains.python.inspections.unusedLocal.PyUnusedFunctionInspection; import com.jetbrains.python.inspections.unusedLocal.PyUnusedLocalInspection; @TestDataPath("$CONTENT_ROOT/../testData//quickFixes/PyRemoveStatementQuickFixTest/") @@ -37,7 +38,7 @@ public class PyRemoveStatementQuickFixTest extends PyQuickFixTestCase { } public void testFunction() { - doQuickFixTest(PyUnusedLocalInspection.class, PyPsiBundle.message("QFIX.NAME.remove.statement")); + doQuickFixTest(PyUnusedFunctionInspection.class, PyPsiBundle.message("QFIX.NAME.remove.statement")); } public void testVariable() { diff --git a/python/testSrc/com/jetbrains/python/testing/PyTestFixtureAndParametrizedTest.kt b/python/testSrc/com/jetbrains/python/testing/PyTestFixtureAndParametrizedTest.kt index 9ab8938c6cb6..b2ba1a0481ba 100644 --- a/python/testSrc/com/jetbrains/python/testing/PyTestFixtureAndParametrizedTest.kt +++ b/python/testSrc/com/jetbrains/python/testing/PyTestFixtureAndParametrizedTest.kt @@ -5,6 +5,7 @@ import com.intellij.idea.TestFor import com.intellij.testFramework.fixtures.CodeInsightTestFixture import com.jetbrains.python.fixtures.PyTestCase import com.jetbrains.python.inspections.unusedLocal.PyUnusedLocalInspection +import com.jetbrains.python.inspections.unusedLocal.PyUnusedParameterInspection import com.jetbrains.python.testing.pyTestParametrized.PyTestParametrizedInspection /** @@ -15,7 +16,8 @@ class PyTestFixtureAndParametrizedTest : PyTestCase() { const val testSubfolder = "/testCompletion" fun testInspectionStatic(fixture: CodeInsightTestFixture) { fixture.configureByFile("test_for_inspection_test.py") - fixture.enableInspections(PyUnusedLocalInspection::class.java, PyTestParametrizedInspection::class.java) + fixture.enableInspections(PyUnusedLocalInspection::class.java, PyUnusedParameterInspection::class.java, + PyTestParametrizedInspection::class.java) fixture.checkHighlighting(true, false, true) } }