1+ package org.digma.intellij.plugin.discovery
2+
3+ import com.intellij.openapi.application.readAction
4+ import com.intellij.openapi.diagnostic.Logger
5+ import com.intellij.openapi.project.Project
6+ import com.intellij.openapi.util.Disposer
7+ import com.intellij.openapi.vfs.VirtualFile
8+ import com.intellij.psi.PsiFile
9+ import com.intellij.psi.PsiManager
10+ import com.intellij.psi.PsiTreeAnyChangeAbstractAdapter
11+ import com.intellij.psi.util.PsiModificationTracker
12+ import kotlinx.coroutines.CancellationException
13+ import kotlinx.coroutines.coroutineScope
14+ import kotlinx.coroutines.delay
15+ import kotlinx.coroutines.ensureActive
16+ import kotlinx.coroutines.isActive
17+ import kotlinx.coroutines.selects.select
18+ import org.digma.intellij.plugin.common.isValidVirtualFile
19+ import org.digma.intellij.plugin.errorreporting.ErrorReporter
20+ import org.digma.intellij.plugin.kotlin.ext.asyncWithErrorReporting
21+ import org.digma.intellij.plugin.kotlin.ext.launchWithErrorReporting
22+ import org.digma.intellij.plugin.log.Log
23+ import java.util.concurrent.atomic.AtomicBoolean
24+ import kotlin.coroutines.coroutineContext
25+ import kotlin.time.Duration
26+ import kotlin.time.Duration.Companion.milliseconds
27+
28+ class FileProcessingMonitor (
29+ private val project : Project ,
30+ private val logger : Logger ,
31+ private val checkInterval : Duration = 100 .milliseconds
32+ ) {
33+
34+ /* *
35+ * Executes a block of code with file change monitoring.
36+ * Cancels the operation if file changes or PSI/stub mismatch is detected.
37+ */
38+ suspend fun <T > executeWithFileMonitoring (
39+ virtualFile : VirtualFile ,
40+ operation : suspend () -> T
41+ ): ProcessingResult <T > = coroutineScope {
42+
43+ val psiFile = readAction { PsiManager .getInstance(project).findFile(virtualFile) }
44+ if (psiFile == null ) {
45+ return @coroutineScope ProcessingResult .Error (" Psi file is null for file ${virtualFile.url} " )
46+ }
47+
48+ val psiTreeChangeDisposable = Disposer .newDisposable()
49+
50+ // Start monitoring.
51+ // The monitorJob will be canceled when a change in the file is detected.
52+ // When the monitorJob is canceled, the select clause will select it and return ProcessingResult.Cancelled.
53+ // The operationJob will be canceled in the finally block, and so the file discovery will be canceled.
54+
55+ val monitorJob = launchWithErrorReporting(" FileProcessingMonitor.executeWithFileMonitoring" , logger) {
56+ val initialSnapshot = createFileSnapshot(virtualFile, psiFile)
57+ if (initialSnapshot == FileProcessingSnapshot .INVALID ) {
58+ Log .trace(logger, project, " FileProcessingMonitor: Cancelling file processing for {} because snapshot is invalid" , virtualFile.url)
59+ throw CancellationException (" File processing cancelled: initial snapshot invalid" )
60+ }
61+ if (! initialSnapshot.isVirtualFileValid || ! initialSnapshot.isPsiFileValid) {
62+ Log .trace(logger, project, " FileProcessingMonitor: Cancelling file processing for {} because virtual file or psi file is invalid" , virtualFile.url)
63+ throw CancellationException (" File processing cancelled: virtual file or psi file invalid" )
64+ }
65+
66+ val psiChanged = AtomicBoolean (false )
67+ PsiManager .getInstance(project).addPsiTreeChangeListener(object : PsiTreeAnyChangeAbstractAdapter () {
68+ override fun onChange (file : PsiFile ? ) {
69+ file?.let {
70+ if (it.virtualFile == virtualFile) {
71+ psiChanged.set(true )
72+ }
73+ }
74+ }
75+ }, psiTreeChangeDisposable)
76+
77+
78+ while (isActive) {
79+ if (psiChanged.get()) {
80+ throw CancellationException (" File processing cancelled: psi tree changed" )
81+ }
82+ delay(checkInterval)
83+ if (psiChanged.get()) {
84+ throw CancellationException (" File processing cancelled: psi tree changed" )
85+ }
86+ monitorFileChanges(virtualFile, psiFile, initialSnapshot)
87+ }
88+ }
89+
90+ // Execute the operation
91+ val operationJob = asyncWithErrorReporting(" FileProcessingMonitor.executeWithFileMonitoring" , logger) {
92+ operation()
93+ }
94+
95+ try {
96+ // Select the first job that completes.
97+ // If monitorJob was canceled due to change in the file, it will be selected and the finally block will cancel
98+ // the operationJob.
99+ // If the operationJob completed, the processing result will be returned, and the finally block will cancel
100+ // the monitorJob
101+ // The select clause is biased towards the first job.
102+ select<ProcessingResult <T >> {
103+ operationJob.onAwait { ProcessingResult .Success (it) }
104+ monitorJob.onJoin { ProcessingResult .Cancelled (" File monitoring detected changes" ) }
105+ }
106+ } catch (@Suppress(" IncorrectCancellationExceptionHandling" ) e: CancellationException ) {
107+ // this is a CancellationException from operationJob.onAwait. it should not be thrown. it will be thrown here in case the operation
108+ // was canceled somewhere else
109+ ProcessingResult .Cancelled (e.message ? : " File monitoring cancelled" )
110+ } catch (e: Exception ) {
111+ ProcessingResult .Error (" Processing failed: ${e.message} " , e)
112+ } finally {
113+ monitorJob.cancel()
114+ operationJob.cancel()
115+ Disposer .dispose(psiTreeChangeDisposable)
116+ }
117+ }
118+
119+ /* *
120+ * Monitor file for changes and PSI/stub consistency
121+ */
122+ private suspend fun monitorFileChanges (file : VirtualFile , psiFile : PsiFile , initialSnapshot : FileProcessingSnapshot ) {
123+ val currentSnapshot = createFileSnapshot(file, psiFile)
124+ coroutineContext.ensureActive()
125+ val changeReason = detectChanges(initialSnapshot, currentSnapshot)
126+ coroutineContext.ensureActive()
127+ if (changeReason != null ) {
128+ Log .trace(logger, project, " FileProcessingMonitor: Cancelling file processing for {}: {}" , file.url, changeReason)
129+ throw CancellationException (" File processing cancelled: $changeReason " )
130+ }
131+ }
132+
133+ /* *
134+ * Create a snapshot of the file's current state
135+ */
136+ private suspend fun createFileSnapshot (file : VirtualFile , psiFile : PsiFile ): FileProcessingSnapshot {
137+ return try {
138+ readAction {
139+ FileProcessingSnapshot (
140+ virtualFileStamp = file.modificationStamp,
141+ isVirtualFileValid = isValidVirtualFile(file),
142+ psiModificationCount = PsiModificationTracker .getInstance(project).modificationCount,
143+ psiFileStamp = psiFile.modificationStamp,
144+ isPsiFileValid = psiFile.isValid,
145+ fileLength = file.length,
146+ psiTextLength = psiFile.textLength
147+ )
148+
149+ }
150+ } catch (e: CancellationException ) {
151+ throw e
152+ } catch (e: Exception ) {
153+ Log .warnWithException(logger, project, e, " FileProcessingMonitor: Failed to create snapshot for {}" , file.url)
154+ ErrorReporter .getInstance().reportError(project, " FileProcessingMonitor.createFileSnapshot" , e)
155+ FileProcessingSnapshot .INVALID
156+ }
157+ }
158+
159+
160+ /* *
161+ * Detect what changed between snapshots
162+ */
163+ private fun detectChanges (
164+ initial : FileProcessingSnapshot ,
165+ current : FileProcessingSnapshot
166+ ): String? {
167+ return when {
168+ ! current.isVirtualFileValid -> " Virtual file became invalid"
169+
170+ current.virtualFileStamp != initial.virtualFileStamp ->
171+ " Virtual file modified"
172+
173+ ! current.isPsiFileValid && initial.isPsiFileValid ->
174+ " PSI file became invalid"
175+
176+ current.psiFileStamp != initial.psiFileStamp && current.psiFileStamp != - 1L ->
177+ " PSI file modified"
178+
179+ current.psiModificationCount != initial.psiModificationCount ->
180+ " PSI tree globally modified"
181+
182+ current.fileLength != initial.fileLength ->
183+ " File length changed"
184+
185+ current.psiTextLength != initial.psiTextLength ->
186+ " PSI text length changed"
187+
188+ else -> null
189+ }
190+ }
191+ }
192+
193+ /* *
194+ * Snapshot of file state for change detection
195+ */
196+ data class FileProcessingSnapshot (
197+ val virtualFileStamp : Long ,
198+ val isVirtualFileValid : Boolean ,
199+ val psiModificationCount : Long ,
200+ val psiFileStamp : Long ,
201+ val isPsiFileValid : Boolean ,
202+ val fileLength : Long ,
203+ val psiTextLength : Int ,
204+ ) {
205+ companion object {
206+ val INVALID = FileProcessingSnapshot (
207+ virtualFileStamp = - 1 ,
208+ isVirtualFileValid = false ,
209+ psiModificationCount = - 1 ,
210+ psiFileStamp = - 1 ,
211+ isPsiFileValid = false ,
212+ fileLength = - 1 ,
213+ psiTextLength = - 1 ,
214+ )
215+ }
216+ }
217+
218+ /* *
219+ * Result of a file processing operation
220+ */
221+ sealed class ProcessingResult <T > {
222+ data class Success <T >(val result : T ) : ProcessingResult<T>()
223+ data class Cancelled <T >(val reason : String ) : ProcessingResult<T>()
224+ data class Error <T >(val message : String , val exception : Exception ? = null ) : ProcessingResult<T>()
225+ }
0 commit comments