From 96d8f685e958fc43dd0a99e25216fe6f99eb13ff Mon Sep 17 00:00:00 2001 From: "Pavel V. Talanov" Date: Thu, 13 Jul 2017 23:51:11 +0300 Subject: [PATCH] Refactor KotlinScriptConfigurationManager: extract parts Introduce ScriptDependenciesUpdater, DependenciesCache and ScriptContentLoader --- ...KotlinScriptExternalImportsProviderImpl.kt | 9 +- ...ProviderBase.kt => ScriptContentLoader.kt} | 4 +- .../idea/core/script/DependenciesCache.kt | 120 +++++++ .../KotlinScriptConfigurationManager.kt | 323 +----------------- .../core/script/ScriptDependenciesUpdater.kt | 234 +++++++++++++ 5 files changed, 376 insertions(+), 314 deletions(-) rename compiler/frontend.script/src/org/jetbrains/kotlin/script/{KotlinScriptExternalImportsProviderBase.kt => ScriptContentLoader.kt} (95%) create mode 100644 idea/idea-core/src/org/jetbrains/kotlin/idea/core/script/DependenciesCache.kt create mode 100644 idea/idea-core/src/org/jetbrains/kotlin/idea/core/script/ScriptDependenciesUpdater.kt diff --git a/compiler/cli/src/org/jetbrains/kotlin/cli/common/script/KotlinScriptExternalImportsProviderImpl.kt b/compiler/cli/src/org/jetbrains/kotlin/cli/common/script/KotlinScriptExternalImportsProviderImpl.kt index 2e2512aaa50..4cfef1260ac 100644 --- a/compiler/cli/src/org/jetbrains/kotlin/cli/common/script/KotlinScriptExternalImportsProviderImpl.kt +++ b/compiler/cli/src/org/jetbrains/kotlin/cli/common/script/KotlinScriptExternalImportsProviderImpl.kt @@ -21,7 +21,7 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile import org.jetbrains.kotlin.script.KotlinScriptDefinitionProvider import org.jetbrains.kotlin.script.KotlinScriptExternalImportsProvider -import org.jetbrains.kotlin.script.KotlinScriptExternalImportsProviderBase +import org.jetbrains.kotlin.script.ScriptContentLoader import java.io.File import java.util.concurrent.locks.ReentrantReadWriteLock import kotlin.concurrent.read @@ -29,12 +29,13 @@ import kotlin.concurrent.write import kotlin.script.dependencies.ScriptDependencies class KotlinScriptExternalImportsProviderImpl( - val project: Project, + project: Project, private val scriptDefinitionProvider: KotlinScriptDefinitionProvider -) : KotlinScriptExternalImportsProviderBase(project) { +) : KotlinScriptExternalImportsProvider { private val cacheLock = ReentrantReadWriteLock() private val cache = hashMapOf() + private val scriptContentLoader = ScriptContentLoader(project) override fun getScriptDependencies(file: VirtualFile): ScriptDependencies? = cacheLock.read { calculateExternalDependencies(file) @@ -47,7 +48,7 @@ class KotlinScriptExternalImportsProviderImpl( else { val scriptDef = scriptDefinitionProvider.findScriptDefinition(file) if (scriptDef != null) { - val deps = resolveDependencies(scriptDef, file) + val deps = scriptContentLoader.loadContentsAndResolveDependencies(scriptDef, file) if (deps != null) { log.info("[kts] new cached deps for $path: ${deps.classpath.joinToString(File.pathSeparator)}") diff --git a/compiler/frontend.script/src/org/jetbrains/kotlin/script/KotlinScriptExternalImportsProviderBase.kt b/compiler/frontend.script/src/org/jetbrains/kotlin/script/ScriptContentLoader.kt similarity index 95% rename from compiler/frontend.script/src/org/jetbrains/kotlin/script/KotlinScriptExternalImportsProviderBase.kt rename to compiler/frontend.script/src/org/jetbrains/kotlin/script/ScriptContentLoader.kt index 3eaa829434a..1436a196080 100644 --- a/compiler/frontend.script/src/org/jetbrains/kotlin/script/KotlinScriptExternalImportsProviderBase.kt +++ b/compiler/frontend.script/src/org/jetbrains/kotlin/script/ScriptContentLoader.kt @@ -28,7 +28,7 @@ import kotlin.reflect.KClass import kotlin.script.dependencies.ScriptContents import kotlin.script.dependencies.ScriptDependencies -abstract class KotlinScriptExternalImportsProviderBase(private val project: Project) : KotlinScriptExternalImportsProvider { +class ScriptContentLoader(private val project: Project) { fun getScriptContents(scriptDefinition: KotlinScriptDefinition, file: VirtualFile) = BasicScriptContents(file, getAnnotations = { loadAnnotations(scriptDefinition, file) }) @@ -57,7 +57,7 @@ abstract class KotlinScriptExternalImportsProviderBase(private val project: Proj override val text: CharSequence? by lazy { virtualFile.inputStream.reader(charset = virtualFile.charset).readText() } } - fun resolveDependencies( + fun loadContentsAndResolveDependencies( scriptDef: KotlinScriptDefinition, file: VirtualFile ): ScriptDependencies? { diff --git a/idea/idea-core/src/org/jetbrains/kotlin/idea/core/script/DependenciesCache.kt b/idea/idea-core/src/org/jetbrains/kotlin/idea/core/script/DependenciesCache.kt new file mode 100644 index 00000000000..c0825d40f60 --- /dev/null +++ b/idea/idea-core/src/org/jetbrains/kotlin/idea/core/script/DependenciesCache.kt @@ -0,0 +1,120 @@ +/* + * Copyright 2010-2017 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jetbrains.kotlin.idea.core.script + +import com.intellij.openapi.extensions.Extensions +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiElementFinder +import com.intellij.psi.search.NonClasspathDirectoriesScope +import java.io.File +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.concurrent.read +import kotlin.concurrent.write +import kotlin.script.dependencies.ScriptDependencies + +internal class DependenciesCache(private val project: Project) { + private val cacheLock = ReentrantReadWriteLock() + private val cache = hashMapOf() + + operator fun get(virtualFile: VirtualFile): ScriptDependencies? = cacheLock.read { cache[virtualFile.path] } + + val allScriptsClasspathCache = ClearableLazyValue(cacheLock) { + val files = cache.values.flatMap { it.classpath }.distinct() + KotlinScriptConfigurationManager.toVfsRoots(files) + } + + val allScriptsClasspathScope = ClearableLazyValue(cacheLock) { + NonClasspathDirectoriesScope(allScriptsClasspathCache.get()) + } + + val allLibrarySourcesCache = ClearableLazyValue(cacheLock) { + KotlinScriptConfigurationManager.toVfsRoots(cache.values.flatMap { it.sources }.distinct()) + } + + val allLibrarySourcesScope = ClearableLazyValue(cacheLock) { + NonClasspathDirectoriesScope(allLibrarySourcesCache.get()) + } + + fun onChange() { + allScriptsClasspathCache.clear() + allScriptsClasspathScope.clear() + allLibrarySourcesCache.clear() + allLibrarySourcesScope.clear() + + val kotlinScriptDependenciesClassFinder = + Extensions.getArea(project).getExtensionPoint(PsiElementFinder.EP_NAME).extensions + .filterIsInstance() + .single() + + kotlinScriptDependenciesClassFinder.clearCache() + } + + fun clear() { + cacheLock.write(cache::clear) + onChange() + } + + fun save(virtualFile: VirtualFile, new: ScriptDependencies): Boolean { + val path = virtualFile.path + val old = cacheLock.write { + val old = cache[path] + cache[path] = new + old + } + return old == null || !new.match(old) + } + + fun delete(virtualFile: VirtualFile): Boolean = cacheLock.write { + cache.remove(virtualFile.path) != null + } +} + +// TODO: relying on this to compare dependencies seems wrong, doesn't take javaHome and other stuff into account +private fun ScriptDependencies.match(other: ScriptDependencies) + = classpath.isSamePathListAs(other.classpath) && + sources.toSet().isSamePathListAs(other.sources.toSet()) // TODO: gradle returns stdlib and reflect sources in unstable order for some reason + + +private fun Iterable.isSamePathListAs(other: Iterable): Boolean = + with(Pair(iterator(), other.iterator())) { + while (first.hasNext() && second.hasNext()) { + if (first.next().canonicalPath != second.next().canonicalPath) return false + } + !(first.hasNext() || second.hasNext()) + } + +internal class ClearableLazyValue(private val lock: ReentrantReadWriteLock, private val compute: () -> T) { + private var value: T? = null + + fun get(): T { + lock.read { + if (value == null) { + lock.write { + value = compute() + } + } + return value!! + } + } + + fun clear() { + lock.write { + value = null + } + } +} \ No newline at end of file diff --git a/idea/idea-core/src/org/jetbrains/kotlin/idea/core/script/KotlinScriptConfigurationManager.kt b/idea/idea-core/src/org/jetbrains/kotlin/idea/core/script/KotlinScriptConfigurationManager.kt index 9ff34494b2d..1e043b4df4b 100644 --- a/idea/idea-core/src/org/jetbrains/kotlin/idea/core/script/KotlinScriptConfigurationManager.kt +++ b/idea/idea-core/src/org/jetbrains/kotlin/idea/core/script/KotlinScriptConfigurationManager.kt @@ -16,37 +16,18 @@ package org.jetbrains.kotlin.idea.core.script -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.ModalityState import com.intellij.openapi.components.ServiceManager import com.intellij.openapi.diagnostic.Logger -import com.intellij.openapi.extensions.Extensions import com.intellij.openapi.project.Project -import com.intellij.openapi.project.ProjectUtil -import com.intellij.openapi.roots.ProjectRootManager -import com.intellij.openapi.roots.ex.ProjectRootManagerEx -import com.intellij.openapi.util.EmptyRunnable import com.intellij.openapi.vfs.StandardFileSystems import com.intellij.openapi.vfs.VirtualFile -import com.intellij.openapi.vfs.VirtualFileManager -import com.intellij.openapi.vfs.newvfs.BulkFileListener -import com.intellij.openapi.vfs.newvfs.events.VFileEvent -import com.intellij.psi.PsiElementFinder -import com.intellij.psi.search.NonClasspathDirectoriesScope import com.intellij.util.io.URLUtil -import kotlinx.coroutines.experimental.asCoroutineDispatcher -import kotlinx.coroutines.experimental.future.future import org.jetbrains.annotations.TestOnly -import org.jetbrains.kotlin.idea.util.application.runWriteAction -import org.jetbrains.kotlin.script.* +import org.jetbrains.kotlin.script.KotlinScriptDefinitionProvider +import org.jetbrains.kotlin.script.KotlinScriptExternalImportsProvider +import org.jetbrains.kotlin.script.makeScriptDefsFromTemplatesProviderExtensions import java.io.File -import java.util.concurrent.CompletableFuture -import java.util.concurrent.Executors.newFixedThreadPool -import java.util.concurrent.locks.ReentrantReadWriteLock -import kotlin.concurrent.read -import kotlin.concurrent.write import kotlin.script.dependencies.ScriptDependencies -import kotlin.script.dependencies.experimental.AsyncDependenciesResolver // NOTE: this service exists exclusively because KotlinScriptConfigurationManager @@ -62,253 +43,26 @@ class IdeScriptExternalImportsProvider( class KotlinScriptConfigurationManager( private val project: Project, private val scriptDefinitionProvider: KotlinScriptDefinitionProvider -) : KotlinScriptExternalImportsProviderBase(project) { - private val cacheLock = ReentrantReadWriteLock() - private val scriptDependencyUpdatesDispatcher = newFixedThreadPool(1).asCoroutineDispatcher() +) { + private val cache = DependenciesCache(project) + private val cacheUpdater = ScriptDependenciesUpdater(project, cache, scriptDefinitionProvider) init { reloadScriptDefinitions() - listenToVfsChanges() } - private fun listenToVfsChanges() { - project.messageBus.connect().subscribe(VirtualFileManager.VFS_CHANGES, object : BulkFileListener.Adapter() { - - val projectFileIndex = ProjectRootManager.getInstance(project).fileIndex - val application = ApplicationManager.getApplication() - - override fun after(events: List) { - if (updateExternalImportsCache(events.mapNotNull { - // The check is partly taken from the BuildManager.java - it.file?.takeIf { - // the isUnitTestMode check fixes ScriptConfigurationHighlighting & Navigation tests, since they are not trigger proper update mechanims - // TODO: find out the reason, then consider to fix tests and remove this check - (application.isUnitTestMode || projectFileIndex.isInContent(it)) && !ProjectUtil.isProjectOrWorkspaceFile(it) - } - })) { - invalidateLocalCaches() - notifyRootsChanged() - } - } - }) - } - - private val allScriptsClasspathCache = ClearableLazyValue(cacheLock) { - val files = cache.values.flatMap { it?.dependencies?.classpath ?: emptyList() }.distinct() - toVfsRoots(files) - } - - private val allScriptsClasspathScope = ClearableLazyValue(cacheLock) { - NonClasspathDirectoriesScope(getAllScriptsClasspath()) - } - - private val allLibrarySourcesCache = ClearableLazyValue(cacheLock) { - toVfsRoots(cache.values.flatMap { it?.dependencies?.sources ?: emptyList() }.distinct()) - } - - private val allLibrarySourcesScope = ClearableLazyValue(cacheLock) { - NonClasspathDirectoriesScope(getAllLibrarySources()) - } - - private fun notifyRootsChanged() { - val rootsChangesRunnable = { - runWriteAction { - if (project.isDisposed) return@runWriteAction - - ProjectRootManagerEx.getInstanceEx(project)?.makeRootsChange(EmptyRunnable.getInstance(), false, true) - ScriptDependenciesModificationTracker.getInstance(project).incModificationCount() - } - } - - val application = ApplicationManager.getApplication() - if (application.isUnitTestMode) { - rootsChangesRunnable.invoke() - } - else { - application.invokeLater(rootsChangesRunnable, ModalityState.defaultModalityState()) - } - } - - fun getScriptClasspath(file: VirtualFile): List = toVfsRoots(getScriptDependencies(file).classpath) - - fun getAllScriptsClasspath(): List = allScriptsClasspathCache.get() - - fun getAllLibrarySources(): List = allLibrarySourcesCache.get() - - fun getAllScriptsClasspathScope() = allScriptsClasspathScope.get() - - fun getAllLibrarySourcesScope() = allLibrarySourcesScope.get() + fun getScriptClasspath(file: VirtualFile): List = toVfsRoots(cacheUpdater.getCurrentDependencies(file).classpath) + fun getScriptDependencies(file: VirtualFile) = cacheUpdater.getCurrentDependencies(file) private fun reloadScriptDefinitions() { val def = makeScriptDefsFromTemplatesProviderExtensions(project, { ep, ex -> log.warn("[kts] Error loading definition from ${ep.id}", ex) }) scriptDefinitionProvider.setScriptDefinitions(def) } - private class TimeStampedRequest(val future: CompletableFuture<*>, val timeStamp: TimeStamp) - - private class DataAndRequest( - val dependencies: ScriptDependencies?, - val modificationStamp: Long?, - val requestInProgress: TimeStampedRequest? = null - ) - - private val cache = hashMapOf() - - override fun getScriptDependencies(file: VirtualFile): ScriptDependencies = cacheLock.read { - val path = file.path - val cached = cache[path] - cached?.dependencies?.let { return it } - - tryLoadingFromDisk(cached, file, path) - - updateExternalImportsCache(listOf(file)) - - return cache[path]?.dependencies ?: ScriptDependencies.Empty - } - - private fun tryLoadingFromDisk(cached: DataAndRequest?, file: VirtualFile, path: String) { - if (cached != null) return - - ScriptDependenciesFileAttribute.read(file)?.let { deserialized -> - save(path, deserialized, file, persist = false) - invalidateLocalCaches() - notifyRootsChanged() - } - } - - private fun updateExternalImportsCache(files: Iterable) = cacheLock.write { - files.mapNotNull { file -> - scriptDefinitionProvider.findScriptDefinition(file)?.let { - updateForFile(file, it) - } - } - }.contains(true) - - private fun updateForFile(file: VirtualFile, scriptDef: KotlinScriptDefinition): Boolean { - if (!file.isValid) { - return cache.remove(file.path) != null - } - - val dependencyResolver = scriptDef.dependencyResolver - if (dependencyResolver is AsyncDependenciesResolver) { - return updateAsync(file, scriptDef, dependencyResolver) - } - return updateSync(file, scriptDef) - } - - private fun updateAsync( - file: VirtualFile, - scriptDefinition: KotlinScriptDefinition, - dependencyResolver: AsyncDependenciesResolver - ): Boolean { - val path = file.path - val oldDataAndRequest = cache[path] - - if (!shouldSendNewRequest(file, oldDataAndRequest)) { - return false - } - - oldDataAndRequest?.requestInProgress?.future?.cancel(true) - - val (currentTimeStamp, newFuture) = sendRequest(path, scriptDefinition, dependencyResolver, file, oldDataAndRequest) - - cache[path] = DataAndRequest( - oldDataAndRequest?.dependencies, - file.modificationStamp, - TimeStampedRequest(newFuture, currentTimeStamp) - ) - return false // not changed immediately - } - - private fun sendRequest( - path: String, - scriptDef: KotlinScriptDefinition, - dependenciesResolver: AsyncDependenciesResolver, - file: VirtualFile, - oldDataAndRequest: DataAndRequest? - ): Pair> { - val currentTimeStamp = TimeStamps.next() - - val newFuture = future(scriptDependencyUpdatesDispatcher) { - dependenciesResolver.resolveAsync( - getScriptContents(scriptDef, file), - (scriptDef as? KotlinScriptDefinitionFromAnnotatedTemplate)?.environment.orEmpty() - ) - }.thenAccept { result -> - cacheLock.read { - val lastTimeStamp = cache[path]?.requestInProgress?.timeStamp - if (lastTimeStamp == currentTimeStamp) { - ServiceManager.getService(project, ScriptReportSink::class.java)?.attachReports(file, result.reports) - if (cacheSync(result.dependencies ?: ScriptDependencies.Empty, oldDataAndRequest?.dependencies, path, file)) { - invalidateLocalCaches() - notifyRootsChanged() - } - } - } - } - return Pair(currentTimeStamp, newFuture) - } - - private fun shouldSendNewRequest(file: VirtualFile, oldDataAndRequest: DataAndRequest?): Boolean { - val currentStamp = file.modificationStamp - val previousStamp = oldDataAndRequest?.modificationStamp - - if (currentStamp != previousStamp) { - return true - } - - return oldDataAndRequest.requestInProgress == null - } - - private fun updateSync(file: VirtualFile, scriptDef: KotlinScriptDefinition): Boolean { - val path = file.path - val oldDeps = cache[path]?.dependencies - val newDeps = resolveDependencies(scriptDef, file) ?: ScriptDependencies.Empty - return cacheSync(newDeps, oldDeps, path, file) - } - - private fun cacheSync( - new: ScriptDependencies, - old: ScriptDependencies?, - path: String, - file: VirtualFile - ): Boolean { - return when { - old == null || !(new.match(old)) -> { - // changed or new - save(path, new, file, persist = true) - true - } - else -> { - save(path, new, file, persist = false) - // same - false - } - } - } - - private fun save(path: String, new: ScriptDependencies?, virtualFile: VirtualFile, persist: Boolean) { - cacheLock.write { - cache.put(path, DataAndRequest(new, virtualFile.modificationStamp)) - } - if (persist && new != null) { - ScriptDependenciesFileAttribute.write(virtualFile, new) - } - } - - private fun invalidateLocalCaches() { - allScriptsClasspathCache.clear() - allScriptsClasspathScope.clear() - allLibrarySourcesCache.clear() - allLibrarySourcesScope.clear() - - val kotlinScriptDependenciesClassFinder = - Extensions.getArea(project).getExtensionPoint(PsiElementFinder.EP_NAME).extensions - .filterIsInstance() - .single() - - kotlinScriptDependenciesClassFinder.clearCache() - } + fun getAllScriptsClasspathScope() = cache.allScriptsClasspathScope.get() + fun getAllLibrarySourcesScope() = cache.allLibrarySourcesScope.get() + fun getAllLibrarySources() = cache.allLibrarySourcesCache.get() + fun getAllScriptsClasspath() = cache.allScriptsClasspathCache.get() companion object { @JvmStatic @@ -336,10 +90,9 @@ class KotlinScriptConfigurationManager( fun updateScriptDependenciesSynchronously(virtualFile: VirtualFile, project: Project) { with(getInstance(project)) { val scriptDefinition = KotlinScriptDefinitionProvider.getInstance(project)!!.findScriptDefinition(virtualFile)!! - val updated = updateSync(virtualFile, scriptDefinition) + val updated = cacheUpdater.updateSync(virtualFile, scriptDefinition) assert(updated) - invalidateLocalCaches() - notifyRootsChanged() + cacheUpdater.onChange() } } @@ -347,54 +100,8 @@ class KotlinScriptConfigurationManager( fun reloadScriptDefinitions(project: Project) { with(getInstance(project)) { reloadScriptDefinitions() - cacheLock.write(cache::clear) - invalidateLocalCaches() + cacheUpdater.clear() } } } } - -private class ClearableLazyValue(private val lock: ReentrantReadWriteLock, private val compute: () -> T) { - private var value: T? = null - - fun get(): T { - lock.read { - if (value == null) { - lock.write { - value = compute() - } - } - return value!! - } - } - - fun clear() { - lock.write { - value = null - } - } -} - -// TODO: relying on this to compare dependencies seems wrong, doesn't take javaHome and other stuff into account -private fun ScriptDependencies.match(other: ScriptDependencies) - = classpath.isSamePathListAs(other.classpath) && - sources.toSet().isSamePathListAs(other.sources.toSet()) // TODO: gradle returns stdlib and reflect sources in unstable order for some reason - - -private fun Iterable.isSamePathListAs(other: Iterable): Boolean = - with(Pair(iterator(), other.iterator())) { - while (first.hasNext() && second.hasNext()) { - if (first.next().canonicalPath != second.next().canonicalPath) return false - } - !(first.hasNext() || second.hasNext()) - } - -data class TimeStamp(private val stamp: Long) { - operator fun compareTo(other: TimeStamp) = this.stamp.compareTo(other.stamp) -} - -object TimeStamps { - private var current: Long = 0 - - fun next() = TimeStamp(current++) -} \ No newline at end of file diff --git a/idea/idea-core/src/org/jetbrains/kotlin/idea/core/script/ScriptDependenciesUpdater.kt b/idea/idea-core/src/org/jetbrains/kotlin/idea/core/script/ScriptDependenciesUpdater.kt new file mode 100644 index 00000000000..88d7c653d2c --- /dev/null +++ b/idea/idea-core/src/org/jetbrains/kotlin/idea/core/script/ScriptDependenciesUpdater.kt @@ -0,0 +1,234 @@ +/* + * Copyright 2010-2017 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jetbrains.kotlin.idea.core.script + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.components.ServiceManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.ProjectUtil +import com.intellij.openapi.roots.ProjectRootManager +import com.intellij.openapi.roots.ex.ProjectRootManagerEx +import com.intellij.openapi.util.EmptyRunnable +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.openapi.vfs.newvfs.BulkFileListener +import com.intellij.openapi.vfs.newvfs.events.VFileEvent +import kotlinx.coroutines.experimental.asCoroutineDispatcher +import kotlinx.coroutines.experimental.future.future +import org.jetbrains.kotlin.idea.util.application.runWriteAction +import org.jetbrains.kotlin.script.* +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import kotlin.script.dependencies.ScriptDependencies +import kotlin.script.dependencies.experimental.AsyncDependenciesResolver + +internal class ScriptDependenciesUpdater( + private val project: Project, + private val cache: DependenciesCache, + private val scriptDefinitionProvider: KotlinScriptDefinitionProvider +) { + private val requests = ConcurrentHashMap() + private val scriptDependencyUpdatesDispatcher = Executors.newFixedThreadPool(1).asCoroutineDispatcher() + private val contentLoader = ScriptContentLoader(project) + + init { + listenToVfsChanges() + } + + private class TimeStampedRequest(val future: CompletableFuture<*>, val timeStamp: TimeStamp) + + private class ModStampedRequest( + val modificationStamp: Long, + val request: TimeStampedRequest? = null + ) + + fun getCurrentDependencies(file: VirtualFile): ScriptDependencies { + cache[file]?.let { return it } + + tryLoadingFromDisk(file) + + updateCache(listOf(file)) + + return cache[file] ?: ScriptDependencies.Empty + } + + private fun tryLoadingFromDisk(file: VirtualFile) { + ScriptDependenciesFileAttribute.read(file)?.let { deserialized -> + cache.save(file, deserialized) + onChange() + } + } + + private fun updateCache(files: Iterable) = + files.map { file -> + if (!file.isValid) { + return cache.delete(file) + } + else { + updateForFile(file) + } + }.contains(true) + + private fun updateForFile(file: VirtualFile): Boolean { + val scriptDef = scriptDefinitionProvider.findScriptDefinition(file) ?: return false + + return when (scriptDef.dependencyResolver) { + is AsyncDependenciesResolver -> { + updateAsync(file, scriptDef) + return false + } + else -> updateSync(file, scriptDef) + } + } + + private fun updateAsync( + file: VirtualFile, + scriptDefinition: KotlinScriptDefinition + ) { + val path = file.path + val lastRequest = requests[path] + + if (!shouldSendNewRequest(file, lastRequest)) { + return + } + + lastRequest?.request?.future?.cancel(true) + + val (currentTimeStamp, newFuture) = sendRequest(file, scriptDefinition) + + requests[path] = ModStampedRequest( + file.modificationStamp, + TimeStampedRequest(newFuture, currentTimeStamp) + ) + return + } + + private fun shouldSendNewRequest(file: VirtualFile, previousRequest: ModStampedRequest?): Boolean { + val currentStamp = file.modificationStamp + val previousStamp = previousRequest?.modificationStamp + + if (currentStamp != previousStamp) { + return true + } + + return previousRequest.request == null + } + + private fun sendRequest( + file: VirtualFile, + scriptDef: KotlinScriptDefinition + ): Pair> { + val currentTimeStamp = TimeStamps.next() + val dependenciesResolver = scriptDef.dependencyResolver as AsyncDependenciesResolver + val path = file.path + + val newFuture = future(scriptDependencyUpdatesDispatcher) { + dependenciesResolver.resolveAsync( + contentLoader.getScriptContents(scriptDef, file), + (scriptDef as? KotlinScriptDefinitionFromAnnotatedTemplate)?.environment.orEmpty() + ) + }.thenAccept { result -> + val lastTimeStamp = requests[path]?.request?.timeStamp + if (lastTimeStamp == currentTimeStamp) { + ServiceManager.getService(project, ScriptReportSink::class.java)?.attachReports(file, result.reports) + if (cache(result.dependencies ?: ScriptDependencies.Empty, file)) { + onChange() + } + } + } + return Pair(currentTimeStamp, newFuture) + } + + fun updateSync(file: VirtualFile, scriptDef: KotlinScriptDefinition): Boolean { + val newDeps = contentLoader.loadContentsAndResolveDependencies(scriptDef, file) ?: ScriptDependencies.Empty + return cache(newDeps, file) + } + + private fun cache( + new: ScriptDependencies, + file: VirtualFile + ): Boolean { + val updated = cache.save(file, new) + if (updated) { + ScriptDependenciesFileAttribute.write(file, new) + } + return updated + } + + fun onChange() { + cache.onChange() + notifyRootsChanged() + } + + private fun notifyRootsChanged() { + val rootsChangesRunnable = { + runWriteAction { + if (project.isDisposed) return@runWriteAction + + ProjectRootManagerEx.getInstanceEx(project)?.makeRootsChange(EmptyRunnable.getInstance(), false, true) + ScriptDependenciesModificationTracker.getInstance(project).incModificationCount() + } + } + + val application = ApplicationManager.getApplication() + if (application.isUnitTestMode) { + rootsChangesRunnable.invoke() + } + else { + application.invokeLater(rootsChangesRunnable, ModalityState.defaultModalityState()) + } + } + + private fun listenToVfsChanges() { + project.messageBus.connect().subscribe(VirtualFileManager.VFS_CHANGES, object : BulkFileListener.Adapter() { + + val projectFileIndex = ProjectRootManager.getInstance(project).fileIndex + val application = ApplicationManager.getApplication() + + override fun after(events: List) { + if (updateCache(events.mapNotNull { + // The check is partly taken from the BuildManager.java + it.file?.takeIf { + // the isUnitTestMode check fixes ScriptConfigurationHighlighting & Navigation tests, since they are not trigger proper update mechanims + // TODO: find out the reason, then consider to fix tests and remove this check + (application.isUnitTestMode || projectFileIndex.isInContent(it)) && !ProjectUtil.isProjectOrWorkspaceFile(it) + } + })) { + onChange() + } + } + }) + } + + fun clear() { + cache.clear() + requests.clear() + } +} + + +private data class TimeStamp(private val stamp: Long) { + operator fun compareTo(other: TimeStamp) = this.stamp.compareTo(other.stamp) +} + +private object TimeStamps { + private var current: Long = 0 + + fun next() = TimeStamp(current++) +} \ No newline at end of file