From eeab79bb120a47ce435f81daf3cb3b230cb5eb69 Mon Sep 17 00:00:00 2001 From: "Andrei.Kuznetsov" Date: Tue, 15 Jul 2025 12:13:12 +0200 Subject: [PATCH] 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 --- .../psi/impl/file/impl/PsiVFSListener.kt | 63 +++++++++++- .../impl/file/impl/ModuleRootTestListener.kt | 32 +++++++ .../impl/file/impl/PsiEventsTestListener.kt | 73 ++++++++++++++ .../psi/impl/file/impl/PsiVFSListenerTest.kt | 95 +++++++++++++++++++ .../META-INF/ProjectModelLangExtensions.xml | 4 + .../openapi/roots/ModuleRootListener.java | 15 ++- 6 files changed, 275 insertions(+), 7 deletions(-) create mode 100644 platform/lang-impl/testSources/com/intellij/psi/impl/file/impl/ModuleRootTestListener.kt create mode 100644 platform/lang-impl/testSources/com/intellij/psi/impl/file/impl/PsiEventsTestListener.kt create mode 100644 platform/lang-impl/testSources/com/intellij/psi/impl/file/impl/PsiVFSListenerTest.kt diff --git a/platform/lang-impl/src/com/intellij/psi/impl/file/impl/PsiVFSListener.kt b/platform/lang-impl/src/com/intellij/psi/impl/file/impl/PsiVFSListener.kt index cb6a302540f0..68b024ce4676 100644 --- a/platform/lang-impl/src/com/intellij/psi/impl/file/impl/PsiVFSListener.kt +++ b/platform/lang-impl/src/com/intellij/psi/impl/file/impl/PsiVFSListener.kt @@ -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() + + 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() + 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 } diff --git a/platform/lang-impl/testSources/com/intellij/psi/impl/file/impl/ModuleRootTestListener.kt b/platform/lang-impl/testSources/com/intellij/psi/impl/file/impl/ModuleRootTestListener.kt new file mode 100644 index 000000000000..3fb3555589c4 --- /dev/null +++ b/platform/lang-impl/testSources/com/intellij/psi/impl/file/impl/ModuleRootTestListener.kt @@ -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") + } +} \ No newline at end of file diff --git a/platform/lang-impl/testSources/com/intellij/psi/impl/file/impl/PsiEventsTestListener.kt b/platform/lang-impl/testSources/com/intellij/psi/impl/file/impl/PsiEventsTestListener.kt new file mode 100644 index 000000000000..4468360fbea5 --- /dev/null +++ b/platform/lang-impl/testSources/com/intellij/psi/impl/file/impl/PsiEventsTestListener.kt @@ -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") + } +} diff --git a/platform/lang-impl/testSources/com/intellij/psi/impl/file/impl/PsiVFSListenerTest.kt b/platform/lang-impl/testSources/com/intellij/psi/impl/file/impl/PsiVFSListenerTest.kt new file mode 100644 index 000000000000..56258bfb8176 --- /dev/null +++ b/platform/lang-impl/testSources/com/intellij/psi/impl/file/impl/PsiVFSListenerTest.kt @@ -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") + } +} \ No newline at end of file diff --git a/platform/platform-resources/src/META-INF/ProjectModelLangExtensions.xml b/platform/platform-resources/src/META-INF/ProjectModelLangExtensions.xml index 9f9b753f87d4..e932a75226ef 100644 --- a/platform/platform-resources/src/META-INF/ProjectModelLangExtensions.xml +++ b/platform/platform-resources/src/META-INF/ProjectModelLangExtensions.xml @@ -127,12 +127,16 @@ + + - * 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. + *

+ * {@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. + *

+ * {@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 {