IJPL-196089 Subscribe PsiVFSModuleRootListener to WorkspaceModelChangeListener

New listener is enabled by default. Use Registry `psi.vfs.listener.over.wsm` to enable legacy behavior. Legacy behavior breaks ExternalAnnotationsTest.testExternalAnnotationsRootRemoved, because PsiVFSModuleRootListener will not receive changes in custom iml entities, therefore, does not invalidate PSI caches on changes to external annotations (see AnnotationUtil.NON_CODE_ANNOTATIONS_PROVIDER)

GitOrigin-RevId: a75670f1e915a0decc13bfa9d5ec6b75be6b21ce
This commit is contained in:
Andrei.Kuznetsov
2025-07-16 12:06:06 +00:00
committed by intellij-monorepo-bot
parent 8ae6643d50
commit eeab79bb12
6 changed files with 275 additions and 7 deletions
@@ -14,6 +14,7 @@ import com.intellij.openapi.components.serviceAsync
import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.diagnostic.trace
import com.intellij.openapi.editor.Document
import com.intellij.openapi.extensions.ExtensionNotApplicableException
import com.intellij.openapi.fileEditor.FileDocumentManagerListener
import com.intellij.openapi.fileEditor.impl.FileDocumentManagerImpl
import com.intellij.openapi.fileTypes.FileTypeEvent
@@ -31,6 +32,9 @@ import com.intellij.openapi.vfs.VirtualFileManager
import com.intellij.openapi.vfs.isTooLarge
import com.intellij.openapi.vfs.newvfs.BulkFileListener
import com.intellij.openapi.vfs.newvfs.events.*
import com.intellij.platform.backend.workspace.WorkspaceModelChangeListener
import com.intellij.platform.workspace.storage.VersionedStorageChange
import com.intellij.platform.workspace.storage.impl.VersionedStorageChangeInternal
import com.intellij.project.stateStore
import com.intellij.psi.*
import com.intellij.psi.impl.DebugUtil
@@ -578,13 +582,62 @@ class PsiVFSListener internal constructor(private val project: Project) {
}
}
internal class PsiVFSModuleRootListener(private val listenerProject: Project) : ModuleRootListener {
/**
* We use [WorkspaceModelChangeListener] in **addition** to [ModuleRootListener], because [ModuleRootListener] may generate events
* not sourced by the workspace model (see Javadoc for [ModuleRootListener]).
* If the same event should trigger both [WorkspaceModelChangeListener] event and [PsiVFSModuleRootListener], these listener invocations
* will be nested and deduplicated inside [PsiVFSModuleRootListenerImpl], so eventually only one [PsiTreeChangeEvent] will be published.
*
* With this listener, we mostly want to invalidate psi caches when workspace model changes.
*/
// @Suppress: Don't use flow instead of [WorkspaceModelChangeListener]. We need to invalidate caches in the same WA as the event.
@Suppress("UsagesOfObsoleteApi")
internal class PsiWsmListener(listenerProject: Project) : WorkspaceModelChangeListener {
private val service = listenerProject.service<PsiVFSModuleRootListenerImpl>()
init {
if (!Registry.`is`("psi.vfs.listener.over.wsm", true)) {
LOG.debug("PsiWsmListener is disabled by registry key")
throw ExtensionNotApplicableException.create()
}
}
private fun isNotEmptyChange(event: VersionedStorageChange): Boolean {
return (event as? VersionedStorageChangeInternal)?.getAllChanges()?.firstOrNull() != null
}
override fun beforeChanged(event: VersionedStorageChange) {
if (isNotEmptyChange(event)) {
service.beforeRootsChange(false)
}
}
override fun changed(event: VersionedStorageChange) {
if (isNotEmptyChange(event)) {
service.rootsChanged(false)
}
}
}
internal class PsiVFSModuleRootListener(listenerProject: Project) : ModuleRootListener {
private val service = listenerProject.service<PsiVFSModuleRootListenerImpl>()
override fun beforeRootsChange(event: ModuleRootEvent) {
service.beforeRootsChange(event.isCausedByFileTypesChange)
}
override fun rootsChanged(event: ModuleRootEvent) {
service.rootsChanged(event.isCausedByFileTypesChange)
}
}
@Service(Service.Level.PROJECT)
private class PsiVFSModuleRootListenerImpl(private val listenerProject: Project) {
// accessed from within write action only
private var depthCounter = 0
override fun beforeRootsChange(event: ModuleRootEvent) {
fun beforeRootsChange(isCausedByFileTypesChange: Boolean) {
LOG.trace { "beforeRootsChanged call" }
if (event.isCausedByFileTypesChange) {
if (isCausedByFileTypesChange) {
return
}
@@ -603,13 +656,13 @@ internal class PsiVFSModuleRootListener(private val listenerProject: Project) :
})
}
override fun rootsChanged(event: ModuleRootEvent) {
fun rootsChanged(isCausedByFileTypesChange: Boolean) {
LOG.trace { "rootsChanged call" }
val psiManager = PsiManagerEx.getInstanceEx(listenerProject)
val fileManager = psiManager.fileManager as FileManagerEx
fileManager.dispatchPendingEvents()
if (event.isCausedByFileTypesChange) {
if (isCausedByFileTypesChange) {
return
}
@@ -0,0 +1,32 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.psi.impl.file.impl
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.roots.ModuleRootEvent
import com.intellij.openapi.roots.ModuleRootListener
class ModuleRootTestListener: ModuleRootListener {
private val myBuffer: StringBuffer = StringBuffer()
private val logger = thisLogger()
private fun logEvent(message: String){
myBuffer.append(message)
logger.debug(Throwable(message))
}
fun reset() {
logger.debug("reset")
myBuffer.setLength(0)
}
val eventsString: String
get() = myBuffer.toString()
override fun beforeRootsChange(event: ModuleRootEvent) {
logEvent("beforeRootsChange\n")
}
override fun rootsChanged(event: ModuleRootEvent) {
logEvent("rootsChanged\n")
}
}
@@ -0,0 +1,73 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.psi.impl.file.impl
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.psi.PsiTreeChangeEvent
import com.intellij.psi.PsiTreeChangeListener
internal class PsiEventsTestListener : PsiTreeChangeListener {
private val myBuffer: StringBuffer = StringBuffer()
private val logger = thisLogger()
fun reset() {
logger.debug("reset")
myBuffer.setLength(0)
}
val eventsString: String
get() = myBuffer.toString()
private fun logEvent(message: String){
myBuffer.append(message)
logger.debug(Throwable(message))
}
override fun beforeChildAddition(event: PsiTreeChangeEvent) {
logEvent("beforeChildAddition\n")
}
override fun beforeChildRemoval(event: PsiTreeChangeEvent) {
logEvent("beforeChildRemoval\n")
}
override fun beforeChildReplacement(event: PsiTreeChangeEvent) {
logEvent("beforeChildReplacement\n")
}
override fun beforeChildMovement(event: PsiTreeChangeEvent) {
logEvent("beforeChildMovement\n")
}
override fun beforeChildrenChange(event: PsiTreeChangeEvent) {
logEvent("beforeChildrenChange\n")
}
override fun beforePropertyChange(event: PsiTreeChangeEvent) {
logEvent("beforePropertyChange ${event.propertyName}\n")
}
override fun childAdded(event: PsiTreeChangeEvent) {
logEvent("childAdded\n")
}
override fun childRemoved(event: PsiTreeChangeEvent) {
logEvent("childRemoved\n")
}
override fun childReplaced(event: PsiTreeChangeEvent) {
logEvent("childReplaced\n")
}
override fun childrenChanged(event: PsiTreeChangeEvent) {
logEvent("childrenChanged\n")
}
override fun childMoved(event: PsiTreeChangeEvent) {
logEvent("childMoved\n")
}
override fun propertyChanged(event: PsiTreeChangeEvent) {
logEvent("propertyChanged ${event.propertyName}\n")
}
}
@@ -0,0 +1,95 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.psi.impl.file.impl
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.writeAction
import com.intellij.openapi.project.RootsChangeRescanningInfo
import com.intellij.openapi.roots.ModuleRootListener
import com.intellij.openapi.roots.ex.ProjectRootManagerEx
import com.intellij.openapi.util.EmptyRunnable
import com.intellij.platform.backend.workspace.WorkspaceModel
import com.intellij.platform.workspace.jps.entities.ModuleCustomImlDataEntity
import com.intellij.platform.workspace.jps.entities.ModuleEntity
import com.intellij.platform.workspace.storage.EntitySource
import com.intellij.psi.impl.PsiManagerEx
import com.intellij.testFramework.IndexingTestUtil
import com.intellij.testFramework.junit5.TestApplication
import com.intellij.testFramework.junit5.TestDisposable
import com.intellij.testFramework.junit5.fixture.projectFixture
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
private object TestEntitySource : EntitySource
@TestApplication
class PsiVFSListenerTest {
val projectFixture = projectFixture(openAfterCreation = true)
@Test
fun testWsmEventGeneratesExactlyOnePsiEvent(@TestDisposable testDisposable: Disposable) = runBlocking {
val project = projectFixture.get()
IndexingTestUtil.suspendUntilIndexesAreReady(project)
val psiListener = PsiEventsTestListener()
val rootListener = ModuleRootTestListener()
project.messageBus.connect(testDisposable).subscribe(ModuleRootListener.TOPIC, rootListener)
PsiManagerEx.getInstanceEx(project).addPsiTreeChangeListener(psiListener, testDisposable)
val wsm = WorkspaceModel.getInstance(project)
val module = ModuleEntity("Test", emptyList(), TestEntitySource)
wsm.update("add module in test") {
it.addEntity(module)
}
assertEquals("beforeRootsChange\nrootsChanged\n", rootListener.eventsString)
assertEquals("beforePropertyChange roots\npropertyChanged roots\n", psiListener.eventsString)
IndexingTestUtil.suspendUntilIndexesAreReady(project) // index just added module (dumb mode may generate psi events)
val imlData = ModuleCustomImlDataEntity(emptyMap(), TestEntitySource) {
this.module = module
}
rootListener.reset()
psiListener.reset()
// intention: check PsiVFSListener generates PSI events correctly when WSM event is triggered, but RootsChangeEvent is not
wsm.update("add custom iml data in test") {
it.addEntity(imlData)
}
assertEquals("", rootListener.eventsString) // check that RootsChangeEvent is indeed not triggered
assertEquals("beforePropertyChange roots\npropertyChanged roots\n", psiListener.eventsString)
}
@Test
fun testMakeRootsChangeTotalRescanGeneratesExactlyOnePsiEvent(@TestDisposable testDisposable: Disposable) {
testMakeRootsChangeGeneratesExactlyOnePsiEvent(testDisposable, RootsChangeRescanningInfo.TOTAL_RESCAN)
}
@Test
fun testMakeRootsChangeNoRescanGeneratesExactlyOnePsiEvent(@TestDisposable testDisposable: Disposable) {
testMakeRootsChangeGeneratesExactlyOnePsiEvent(testDisposable, RootsChangeRescanningInfo.NO_RESCAN_NEEDED)
}
private fun testMakeRootsChangeGeneratesExactlyOnePsiEvent(testDisposable: Disposable, changes: RootsChangeRescanningInfo): Unit = runBlocking {
val project = projectFixture.get()
IndexingTestUtil.suspendUntilIndexesAreReady(project)
val psiListener = PsiEventsTestListener()
val rootListener = ModuleRootTestListener()
project.messageBus.connect(testDisposable).subscribe(ModuleRootListener.TOPIC, rootListener)
PsiManagerEx.getInstanceEx(project).addPsiTreeChangeListener(psiListener, testDisposable)
writeAction {
ProjectRootManagerEx.getInstanceEx(project).makeRootsChange(EmptyRunnable.getInstance(), changes)
}
assertEquals(rootListener.eventsString, "beforeRootsChange\nrootsChanged\n")
assertEquals(psiListener.eventsString, "beforePropertyChange roots\npropertyChanged roots\n")
}
}
@@ -127,12 +127,16 @@
<registryKey key="unknown.sdk.auto" defaultValue="true" description="Checks and resolves unknown SDKs automatically on start"/>
<registryKey key="unknown.sdk.modal.jps" defaultValue="true" description="Run unknown JDK test before JPS build is started"/>
<registryKey key="unknown.sdk.show.editor.actions" defaultValue="true" description="Show editor suggestions to fix missing SKDs"/>
<registryKey key="psi.vfs.listener.over.wsm" defaultValue="true" restartRequired="true"
description="Use WSM listeners to invalidate PSI caches (new) in addition to legacy ModuleRootListener"/>
<editorNotificationProvider implementation="com.intellij.openapi.projectRoots.impl.UnknownSdkEditorNotificationsProvider"/>
</extensions>
<projectListeners>
<listener class="com.intellij.psi.impl.file.impl.PsiVFSModuleRootListener"
topic="com.intellij.openapi.roots.ModuleRootListener"/>
<listener class="com.intellij.psi.impl.file.impl.PsiWsmListener"
topic="com.intellij.platform.backend.workspace.WorkspaceModelChangeListener"/>
<listener class="com.intellij.workspaceModel.ide.impl.legacyBridge.facet.FacetEntityChangeListener$WorkspaceModelListener"
topic="com.intellij.platform.backend.workspace.WorkspaceModelChangeListener"/>
<listener class="com.intellij.workspaceModel.ide.impl.legacyBridge.module.ModuleBridgeCleaner"
@@ -1,6 +1,8 @@
// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.openapi.roots;
import com.intellij.openapi.project.RootsChangeRescanningInfo;
import com.intellij.openapi.roots.ex.ProjectRootManagerEx;
import com.intellij.util.messages.Topic;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
@@ -10,8 +12,17 @@ import java.util.EventListener;
/**
* Project root changes.
* <p>
* Instead of events with {{@link ModuleRootEvent#isCausedByWorkspaceModelChangesOnly()}} one may use
* {@link com.intellij.platform.backend.workspace.WorkspaceModelChangeListener} and get more fine-grained incremental events.
* Instead of events with {@link ModuleRootEvent#isCausedByWorkspaceModelChangesOnly()} one may use
* {@link com.intellij.platform.backend.workspace.WorkspaceModelChangeListener} and get more fine-grained incremental events.
* <p>
* {@link ModuleRootEvent#isCausedByWorkspaceModelChangesOnly()} is always {@code false} in the {@linkplain #beforeRootsChange(ModuleRootEvent)}.
* This is because {@linkplain ModuleRootListener} will de-duplicate nested {@linkplain ModuleRootEvent} events, and at the moment of the
* {@linkplain #beforeRootsChange(ModuleRootEvent)} invocation we don't know if all the events (including de-duplicated) are from WSM or not.
* <p>
* {@linkplain com.intellij.platform.backend.workspace.WorkspaceModelChangeListener} is not a direct replacement for {@linkplain ModuleRootListener},
* because {@linkplain ModuleRootListener} may generate events that are not related to the workspace model. For example, there will be an
* event on a filetype change, roots validity change (VirtualFileUrl validity change like when a jar file is downloaded), and
* out of thin air events caused by {@link ProjectRootManagerEx#makeRootsChange(Runnable, RootsChangeRescanningInfo)}
*/
@ApiStatus.OverrideOnly
public interface ModuleRootListener extends EventListener {