From bcf0ff8249bd8ee445df4efd79344f4b1a78d8af Mon Sep 17 00:00:00 2001 From: "evgeny.bovykin" Date: Tue, 21 Oct 2025 10:23:28 +0200 Subject: [PATCH] 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 --- .../resources/intellij.python.psi.impl.xml | 2 + .../inspections/PyChangeLocalityDetector.kt | 18 ++++ .../PyChangeLocalityDetectorTest.kt | 98 +++++++++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 python/python-psi-impl/src/com/jetbrains/python/inspections/PyChangeLocalityDetector.kt create mode 100644 python/testSrc/com/jetbrains/python/inspections/PyChangeLocalityDetectorTest.kt 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 8e823ed9f305..e830886909b5 100644 --- a/python/python-psi-impl/resources/intellij.python.psi.impl.xml +++ b/python/python-psi-impl/resources/intellij.python.psi.impl.xml @@ -189,6 +189,8 @@ + + diff --git a/python/python-psi-impl/src/com/jetbrains/python/inspections/PyChangeLocalityDetector.kt b/python/python-psi-impl/src/com/jetbrains/python/inspections/PyChangeLocalityDetector.kt new file mode 100644 index 000000000000..9270fda4f319 --- /dev/null +++ b/python/python-psi-impl/src/com/jetbrains/python/inspections/PyChangeLocalityDetector.kt @@ -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 + } +} \ No newline at end of file diff --git a/python/testSrc/com/jetbrains/python/inspections/PyChangeLocalityDetectorTest.kt b/python/testSrc/com/jetbrains/python/inspections/PyChangeLocalityDetectorTest.kt new file mode 100644 index 000000000000..8b367383d1c6 --- /dev/null +++ b/python/testSrc/com/jetbrains/python/inspections/PyChangeLocalityDetectorTest.kt @@ -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(): + x = 1 + """, null) + + fun `test top level function whitespace at the start change`() = checkChangeScope(""" + + 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 + + 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 + + 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 ", 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") + } + } +}