PY-63802 Implement PyChangeLocalityDetector to fix disappearing highlighting when changing whitespace at the top level

By default, when only changing whitespace at the top level, DefaultChangeLocalityDetector would trigger and say that only the changed whitespace should be rehighlighted. However, highlighting for a whole file would still be dropped. After that, PyUnusedLocalInspection would not be triggered for any function in that file, meaning it would not re-highlight them, leading to an inspection warning disappearing.

Now, PyChangeLocalityDetector triggers when changing a whitespace on a top level and triggers re-highlighting of the whole file

Performance consideration: re-highlighting the whole file might be expensive (that's why ChangeLocalityDetector was introduced in the first place). However, no other ChangeLocalityDetector is implemented for Python, so most of the time the whole file is re-highlighted anyways. Re-highlighting the whole file in a new niche case of changing whitespace at the end of the file shouldn't lead to any significant performance impact.

GitOrigin-RevId: 8f26c59da8501f8eac78d1280888dd49ffef28d4
This commit is contained in:
evgeny.bovykin
2025-10-28 19:14:41 +00:00
committed by intellij-monorepo-bot
parent 43b8e822c8
commit bcf0ff8249
3 changed files with 118 additions and 0 deletions
@@ -189,6 +189,8 @@
<annotator language="Python" implementationClass="com.jetbrains.python.validation.PyBuiltinHighlightingAnnotator"/>
<annotator language="Python" implementationClass="com.jetbrains.python.validation.UnsupportedFeatures"/>
<daemon.changeLocalityDetector implementation="com.jetbrains.python.inspections.PyChangeLocalityDetector"/>
<localInspection language="Python" shortName="PyUnusedLocalInspection" suppressId="PyUnusedLocal" bundle="messages.PyPsiBundle" key="INSP.NAME.unused" groupKey="INSP.GROUP.python" enabledByDefault="true" level="WEAK WARNING" implementationClass="com.jetbrains.python.inspections.unusedLocal.PyUnusedLocalInspection"/>
<localInspection language="Python" shortName="PyRedundantParenthesesInspection" suppressId="PyRedundantParentheses" bundle="messages.PyPsiBundle" key="INSP.NAME.redundant.parentheses" groupKey="INSP.GROUP.python" enabledByDefault="true" level="WEAK WARNING" implementationClass="com.jetbrains.python.inspections.PyRedundantParenthesesInspection"/>
<localInspection language="Python" shortName="PySimplifyBooleanCheckInspection" suppressId="PySimplifyBooleanCheck" bundle="messages.PyPsiBundle" key="INSP.NAME.check.can.be.simplified" groupKey="INSP.GROUP.python" enabledByDefault="true" level="WEAK WARNING" implementationClass="com.jetbrains.python.inspections.PySimplifyBooleanCheckInspection"/>
@@ -0,0 +1,18 @@
package com.jetbrains.python.inspections
import com.intellij.codeInsight.daemon.ChangeLocalityDetector
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiWhiteSpace
import com.jetbrains.python.psi.PyFile
class PyChangeLocalityDetector : ChangeLocalityDetector {
override fun getChangeHighlightingDirtyScopeFor(changedElement: PsiElement): PsiElement? {
if (changedElement is PsiWhiteSpace) {
val parent = changedElement.parent
if (parent is PyFile) {
return parent
}
}
return null
}
}
@@ -0,0 +1,98 @@
package com.jetbrains.python.inspections
import com.intellij.psi.PsiElement
import com.intellij.psi.util.PsiTreeUtil
import com.jetbrains.python.fixtures.PyTestCase
import com.jetbrains.python.psi.PyFile
import junit.framework.TestCase
class PyChangeLocalityDetectorTest : PyTestCase() {
fun `test top level function body change`() = checkChangeScope("""
def f():
<selection>x = 1</selection>
""", null)
fun `test top level function whitespace at the start change`() = checkChangeScope("""
<selection>
</selection>def f():
x = 1
def bar():
pass
""".trimIndent(), """
def f():
x = 1
def bar():
pass
""".trimIndent())
fun `test top level function whitespace at the end change`() = checkChangeScope("""
def f():
x = 1<selection>
</selection>def bar():
pass
""".trimIndent(), """
def f():
x = 1
def bar():
pass
""".trimIndent())
fun `test top level class whitespace at the end change`() = checkChangeScope("""
class A:
x = 1<selection>
</selection>def bar():
pass
""".trimIndent(), """
class A:
x = 1
def bar():
pass
""".trimIndent())
private fun checkChangeScope(text: String, expectedScope: String?) {
myFixture.configureByText("main.py", text)
val selectionModel = myFixture.editor.selectionModel
assertTrue("changed item has to be specified in <selection></selection>", selectionModel.hasSelection())
var element = PsiTreeUtil.findElementOfClassAtRange(
myFixture.file,
selectionModel.selectionStart,
selectionModel.selectionEnd,
PsiElement::class.java
) ?: error("No PsiElement at selection range")
val changeLocalityDetector = PyChangeLocalityDetector()
/**
* Emulate [com.intellij.codeInsight.daemon.impl.PsiChangeHandler.updateByChange] logic
* by walking up the tree and calling [com.intellij.codeInsight.daemon.ChangeLocalityDetector.getChangeHighlightingDirtyScopeFor]
* until non-null is returned
*/
while (element !is PyFile) {
val dirtyScope = changeLocalityDetector.getChangeHighlightingDirtyScopeFor(element)
if (dirtyScope != null) {
if (expectedScope == null) {
throw IllegalStateException("scope was calculated when no scope was expected")
}
TestCase.assertEquals(expectedScope.trimIndent(), dirtyScope.text)
return
}
element = element.parent
}
if (expectedScope != null) {
throw IllegalStateException("scope wasn't calculated")
}
}
}