diff --git a/python/python-psi-impl/src/com/jetbrains/python/codeInsight/controlflow/CallInstruction.kt b/python/python-psi-impl/src/com/jetbrains/python/codeInsight/controlflow/CallInstruction.kt index 1cbdbcff7206b..b252249226187 100644 --- a/python/python-psi-impl/src/com/jetbrains/python/codeInsight/controlflow/CallInstruction.kt +++ b/python/python-psi-impl/src/com/jetbrains/python/codeInsight/controlflow/CallInstruction.kt @@ -1,6 +1,9 @@ package com.jetbrains.python.codeInsight.controlflow import com.intellij.codeInsight.controlflow.ControlFlowBuilder +import com.intellij.codeInsight.controlflow.Instruction +import com.intellij.psi.util.CachedValueProvider +import com.intellij.psi.util.CachedValuesManager import com.intellij.codeInsight.controlflow.impl.InstructionImpl import com.jetbrains.python.psi.PyCallExpression import com.jetbrains.python.psi.PyFunction @@ -20,6 +23,11 @@ class CallInstruction(builder: ControlFlowBuilder, call: PyCallExpression) : Ins if (pyFunction is PyFunction && hasReturnTypeAnnotation(pyFunction)) { return context.getReturnType(pyFunction) is PyNeverType } + // Fallback: look into the callee's body control-flow. If the function cannot + // complete normally (no path reaches the function exit), treat it as no-return. + if (pyFunction is PyFunction) { + return functionIsEffectivelyNoReturn(pyFunction) + } } return false } @@ -27,4 +35,50 @@ class CallInstruction(builder: ControlFlowBuilder, call: PyCallExpression) : Ins private fun hasReturnTypeAnnotation(function: PyFunction): Boolean { return function.annotation != null || function.typeCommentAnnotation != null +} + +private fun functionIsEffectivelyNoReturn(function: PyFunction): Boolean { + // Cache per-function, drop on any PSI modification + val manager = CachedValuesManager.getManager(function.project) + return manager.getCachedValue(function) { + val flow = ControlFlowCache.getControlFlow(function) + val instructions = flow.instructions + + // Heuristic/performance guard: don't analyze very large CFGs + // (extremely unlikely small helper functions will exceed this) + val maxInstructionsToAnalyze = 1000 + val result = if (instructions.isEmpty() || instructions.size > maxInstructionsToAnalyze) { + false + } else { + !exitIsReachable(instructions) + } + + CachedValueProvider.Result.create(result, function) + } +} + +private fun exitIsReachable(instructions: Array): Boolean { + // Start is always at index 0; exit node is the last instruction + val start = instructions.first() + val exit = instructions.last() + // Simple iterative DFS without version checks (consistent with CFG text tests) + val visited = BooleanArray(instructions.size) + val stack = java.util.ArrayDeque() + stack.addFirst(start) + + var steps = 0 + val maxSteps = 20000 // safety guard against pathological graphs + + while (!stack.isEmpty() && steps++ < maxSteps) { + val insn = stack.removeFirst() + val num = insn.num() + if (visited[num]) continue + visited[num] = true + if (insn === exit) return true + val succs = insn.allSucc() + for (succ in succs) { + if (!visited[succ.num()]) stack.addFirst(succ) + } + } + return visited[exit.num()] } \ No newline at end of file diff --git a/python/testData/codeInsight/controlflow/ControlFlowIsAbruptAfterRaisingCallee.py b/python/testData/codeInsight/controlflow/ControlFlowIsAbruptAfterRaisingCallee.py new file mode 100644 index 0000000000000..a4d66cedd0b0a --- /dev/null +++ b/python/testData/codeInsight/controlflow/ControlFlowIsAbruptAfterRaisingCallee.py @@ -0,0 +1,5 @@ +def die(): + raise RuntimeError('no way') + +die() +print("ureachable") diff --git a/python/testData/codeInsight/controlflow/ControlFlowIsAbruptAfterRaisingCallee.txt b/python/testData/codeInsight/controlflow/ControlFlowIsAbruptAfterRaisingCallee.txt new file mode 100644 index 0000000000000..1f603dd430663 --- /dev/null +++ b/python/testData/codeInsight/controlflow/ControlFlowIsAbruptAfterRaisingCallee.txt @@ -0,0 +1,8 @@ +0(1) element: null +1(2) element: PyFunction('die') +2(3) WRITE ACCESS: die +3(4) element: PyExpressionStatement +4(5) READ ACCESS: die +5(6) element: PyCallExpression: die +6(7) element: PyPrintStatement +7() element: null diff --git a/python/testSrc/com/jetbrains/python/PyControlFlowBuilderTest.java b/python/testSrc/com/jetbrains/python/PyControlFlowBuilderTest.java index ca060bcff1d14..d54f91f288a3d 100644 --- a/python/testSrc/com/jetbrains/python/PyControlFlowBuilderTest.java +++ b/python/testSrc/com/jetbrains/python/PyControlFlowBuilderTest.java @@ -544,6 +544,11 @@ public void testControlFlowIsAbruptAfterNoReturn() { doTest(); } + // PY-72253 + public void testControlFlowIsAbruptAfterRaisingCallee() { + doTest(); + } + // TODO migrate this test class to Python 3 SDK by default to make this test work // PY-53703 //public void testControlFlowIsAbruptAfterNever() {