From b6444b4359ec5e934029f106f6ab5b7211c134fc Mon Sep 17 00:00:00 2001 From: Vlad Koshkin Date: Tue, 24 Jun 2025 13:03:05 +0200 Subject: [PATCH] [kotlin] fix scripting API compatibility issue #IJPL-191977 (cherry picked from commit 1cf2a68745204e9538c14fb120615ae9a42e7f42) IJ-CR-167091 GitOrigin-RevId: 6cf680faf006f800f1f8253d73aef78ad893a382 --- .../script/k1/ScriptConfigurationManager.kt | 2 +- .../settings/KotlinScriptingSettingsImpl.kt | 6 +- .../KotlinScriptingSettingsConfigurable.kt | 2 +- .../ScriptDefinitionPersistentSettings.kt | 63 ++-- .../core/script/ScriptDefinitionsManager.kt | 294 ++++++++++++++++++ .../idea/core/script/ScriptDependencyAware.kt | 2 + .../settings/KotlinScriptingSettings.kt | 3 + 7 files changed, 343 insertions(+), 29 deletions(-) create mode 100644 plugins/kotlin/base/scripting/src/org/jetbrains/kotlin/idea/core/script/ScriptDefinitionsManager.kt diff --git a/plugins/kotlin/base/scripting.k1/src/org/jetbrains/kotlin/idea/core/script/k1/ScriptConfigurationManager.kt b/plugins/kotlin/base/scripting.k1/src/org/jetbrains/kotlin/idea/core/script/k1/ScriptConfigurationManager.kt index 990f816af030..dc2d55859852 100644 --- a/plugins/kotlin/base/scripting.k1/src/org/jetbrains/kotlin/idea/core/script/k1/ScriptConfigurationManager.kt +++ b/plugins/kotlin/base/scripting.k1/src/org/jetbrains/kotlin/idea/core/script/k1/ScriptConfigurationManager.kt @@ -125,7 +125,7 @@ class ScriptConfigurationManager(val myProject: Project, val scope: CoroutineSco fun getLightScriptInfo(file: String): LightScriptInfo? = updater.classpathRoots.getLightScriptInfo(file) - fun updateScriptDefinitionReferences() { + override fun updateScriptDefinitionReferences() { ScriptDependenciesModificationTracker.getInstance(project).incModificationCount() default.updateScriptDefinitionsReferences() diff --git a/plugins/kotlin/base/scripting.k1/src/org/jetbrains/kotlin/idea/core/script/k1/settings/KotlinScriptingSettingsImpl.kt b/plugins/kotlin/base/scripting.k1/src/org/jetbrains/kotlin/idea/core/script/k1/settings/KotlinScriptingSettingsImpl.kt index 74dc7c3a2162..d70473f5e65a 100644 --- a/plugins/kotlin/base/scripting.k1/src/org/jetbrains/kotlin/idea/core/script/k1/settings/KotlinScriptingSettingsImpl.kt +++ b/plugins/kotlin/base/scripting.k1/src/org/jetbrains/kotlin/idea/core/script/k1/settings/KotlinScriptingSettingsImpl.kt @@ -84,7 +84,7 @@ class KotlinScriptingSettingsImpl(private val project: Project) : PersistentStat } } - fun setOrder(scriptDefinition: ScriptDefinition, order: Int) { + override fun setOrder(scriptDefinition: ScriptDefinition, order: Int) { scriptDefinitions[scriptDefinition.toKey()] = scriptDefinitions[scriptDefinition.toKey()]?.copy(order = order) ?: KotlinScriptDefinitionValue(order) } @@ -107,11 +107,11 @@ class KotlinScriptingSettingsImpl(private val project: Project) : PersistentStat ) } - fun getScriptDefinitionOrder(scriptDefinition: ScriptDefinition): Int { + override fun getScriptDefinitionOrder(scriptDefinition: ScriptDefinition): Int { return scriptDefinitions[scriptDefinition.toKey()]?.order ?: KotlinScriptDefinitionValue.DEFAULT.order } - fun isScriptDefinitionEnabled(scriptDefinition: ScriptDefinition): Boolean { + override fun isScriptDefinitionEnabled(scriptDefinition: ScriptDefinition): Boolean { return scriptDefinitions[scriptDefinition.toKey()]?.isEnabled ?: KotlinScriptDefinitionValue.DEFAULT.isEnabled } diff --git a/plugins/kotlin/base/scripting.k2/src/org/jetbrains/kotlin/idea/core/script/k2/settings/KotlinScriptingSettingsConfigurable.kt b/plugins/kotlin/base/scripting.k2/src/org/jetbrains/kotlin/idea/core/script/k2/settings/KotlinScriptingSettingsConfigurable.kt index 0a8befdf6616..6a47cdde8f54 100644 --- a/plugins/kotlin/base/scripting.k2/src/org/jetbrains/kotlin/idea/core/script/k2/settings/KotlinScriptingSettingsConfigurable.kt +++ b/plugins/kotlin/base/scripting.k2/src/org/jetbrains/kotlin/idea/core/script/k2/settings/KotlinScriptingSettingsConfigurable.kt @@ -99,7 +99,7 @@ internal class KotlinScriptingSettingsConfigurable(val project: Project, val cor override fun apply() { if (isScriptDefinitionsChanged()) { val settings = model.items.mapIndexed { index, item -> - ScriptDefinitionSetting( + ScriptDefinitionPersistentSettings.ScriptDefinitionSetting( item.definition.definitionId, item.isEnabled ) diff --git a/plugins/kotlin/base/scripting.k2/src/org/jetbrains/kotlin/idea/core/script/k2/settings/ScriptDefinitionPersistentSettings.kt b/plugins/kotlin/base/scripting.k2/src/org/jetbrains/kotlin/idea/core/script/k2/settings/ScriptDefinitionPersistentSettings.kt index e11b7eefd929..696f7ddde418 100644 --- a/plugins/kotlin/base/scripting.k2/src/org/jetbrains/kotlin/idea/core/script/k2/settings/ScriptDefinitionPersistentSettings.kt +++ b/plugins/kotlin/base/scripting.k2/src/org/jetbrains/kotlin/idea/core/script/k2/settings/ScriptDefinitionPersistentSettings.kt @@ -3,50 +3,65 @@ package org.jetbrains.kotlin.idea.core.script.k2.settings import com.intellij.openapi.components.* import com.intellij.openapi.project.Project +import com.intellij.util.xmlb.annotations.Attribute import org.jetbrains.kotlin.idea.core.script.k2.definitions.ScriptDefinitionProviderImpl import org.jetbrains.kotlin.idea.core.script.settings.KotlinScriptingSettings import org.jetbrains.kotlin.scripting.definitions.ScriptDefinition @State( - name = "ScriptDefinitionSettings", - storages = [Storage(StoragePathMacros.WORKSPACE_FILE)] + name = "ScriptDefinitionSettings", storages = [Storage(StoragePathMacros.WORKSPACE_FILE)] ) class ScriptDefinitionPersistentSettings(val project: Project) : - SimplePersistentStateComponent(DefinitionSettingsState()), KotlinScriptingSettings { + SerializablePersistentStateComponent(State()), KotlinScriptingSettings { fun getIndexedSettingsPerDefinition(): Map = state.settings.mapIndexedTo(mutableListOf()) { index, it -> it.definitionId to IndexedSetting(index, it) }.toMap() fun setSettings(settings: List) { - loadState(DefinitionSettingsState(settings.toMutableList())) + updateState { + it.copy(settings = settings) + } ScriptDefinitionProviderImpl.getInstance(project).notifyDefinitionsChanged() } override fun autoReloadConfigurations(scriptDefinition: ScriptDefinition): Boolean = true override fun setAutoReloadConfigurations(scriptDefinition: ScriptDefinition, autoReloadScriptDependencies: Boolean): Unit = Unit + override fun setOrder(scriptDefinition: ScriptDefinition, order: Int) { + val settings = state.settings.toMutableList() + if (settings[order].definitionId == scriptDefinition.definitionId) return + + settings.removeIf { it.definitionId == scriptDefinition.definitionId } + settings[order] = ScriptDefinitionSetting(scriptDefinition.definitionId, true) + + updateState { + it.copy(settings = settings) + } + } + + override fun isScriptDefinitionEnabled(scriptDefinition: ScriptDefinition): Boolean { + return state.settings.firstOrNull { it.definitionId == scriptDefinition.definitionId }?.enabled ?: true + } + + override fun getScriptDefinitionOrder(scriptDefinition: ScriptDefinition): Int { + val order = state.settings.indexOfFirst { it.definitionId == scriptDefinition.definitionId } + return if (order == -1) Integer.MAX_VALUE else order + } + companion object { - fun getInstance(project: Project): ScriptDefinitionPersistentSettings = project.service() as ScriptDefinitionPersistentSettings - } -} - -class DefinitionSettingsState() : BaseState() { - constructor(settings: MutableList) : this() { - this.settings = settings + fun getInstance(project: Project): ScriptDefinitionPersistentSettings = + project.service() as ScriptDefinitionPersistentSettings } - var settings: MutableList by list() + data class State( + @Attribute @JvmField var settings: List = listOf() + ) + + data class ScriptDefinitionSetting( + @Attribute @JvmField val definitionId: String? = null, + @Attribute @JvmField val enabled: Boolean = true, + ) + + class IndexedSetting(val index: Int, val setting: ScriptDefinitionSetting) } - -open class ScriptDefinitionSetting() : BaseState() { - constructor(definitionId: String, enabled: Boolean) : this() { - this.definitionId = definitionId - this.enabled = enabled - } - - var definitionId: String? by string() - var enabled: Boolean by property(true) -} - -class IndexedSetting(val index: Int, val setting: ScriptDefinitionSetting) \ No newline at end of file diff --git a/plugins/kotlin/base/scripting/src/org/jetbrains/kotlin/idea/core/script/ScriptDefinitionsManager.kt b/plugins/kotlin/base/scripting/src/org/jetbrains/kotlin/idea/core/script/ScriptDefinitionsManager.kt new file mode 100644 index 000000000000..6b2316fa833b --- /dev/null +++ b/plugins/kotlin/base/scripting/src/org/jetbrains/kotlin/idea/core/script/ScriptDefinitionsManager.kt @@ -0,0 +1,294 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.kotlin.idea.core.script + +import com.intellij.ide.scratch.ScratchFileService +import com.intellij.ide.scratch.ScratchRootType +import com.intellij.injected.editor.VirtualFileWindow +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.application.runWriteAction +import com.intellij.openapi.diagnostic.ControlFlowException +import com.intellij.openapi.fileTypes.FileTypeManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFileManager +import org.jetbrains.annotations.ApiStatus +import org.jetbrains.kotlin.idea.KotlinFileType +import org.jetbrains.kotlin.idea.core.script.settings.KotlinScriptingSettings +import org.jetbrains.kotlin.scripting.definitions.ScriptDefinition +import org.jetbrains.kotlin.scripting.definitions.ScriptDefinitionsSource +import org.jetbrains.kotlin.scripting.resolve.VirtualFileScriptSource +import org.jetbrains.kotlin.utils.addToStdlib.flattenTo +import org.jetbrains.kotlin.utils.addToStdlib.measureTimeMillisWithResult +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock +import kotlin.script.experimental.api.SourceCode + +/** + * [ScriptDefinitionsManager] is a project service responsible for loading/caching and searching [ScriptDefinition]s. + * + * Definitions are organized in a sequential list. To locate a definition that matches a script, we need to identify + * the first one where the [ScriptDefinition.isScript] function returns `true`. + * + * The order of definitions is defined by [ScriptDefinitionsSource]s they are discovered and loaded by. Since every source + * provides its own definitions list, the resulting one is partitioned. Partitions in their turn are sorted. + * E.g. if definition sources are ordered as (A, B, C), their definitions might look as ((A-def-1, A-def-2), (B-def), (C-def)). + * + * Their order is crucial because it affects definition search algo. + * + * In rare exceptional cases, the resulting definitions' order might be inaccurate and doesn't accommodate the user's needs. + * The actual matching definition precedes the one that is desired. As a workaround, all methods and properties exposing definitions consider + * [KotlinScriptingSettingsImpl] - UI-manageable settings defining "correct" order. Explicit [reorderScriptDefinitions] method exists solely for + * this purpose. + * + * **Note** that the class is `open` for inheritance only for the testing purpose. Its dependencies are cut via a set of `protected open` + * methods. + */ +@ApiStatus.Internal +open class ScriptDefinitionsManager(private val project: Project) : IdeScriptDefinitionProvider(), Disposable { + + companion object { + fun getInstance(project: Project): IdeScriptDefinitionProvider = IdeScriptDefinitionProvider.getInstance(project) + } + + // Support for insertion order is crucial because 'getSources()' is based on EP order in XML (default configuration source goes last) + private val definitionsBySource = mutableMapOf>() + + private val activatedDefinitionSources: MutableSet = ConcurrentHashMap.newKeySet() + + private val failedContributorsHashes: MutableSet = ConcurrentHashMap.newKeySet() + + private val definitionsLock = ReentrantLock() + + @Volatile + private var definitions: List? = null + + /** + * Property generates a sequence of all discovered definitions. + * Definitions disabled via [KotlinScriptingSettingsImpl.isScriptDefinitionEnabled] are filtered out. + * The sequence is ordered according to the [KotlinScriptingSettingsImpl.getScriptDefinitionOrder] or, if the latter is missing, + * conforms default by-source order (see [ScriptDefinitionsManager]). + * @see [getDefinitions] + */ + public override val currentDefinitions: Sequence + get() { + val scriptingSettings = getKotlinScriptingSettings() + return getOrLoadDefinitions().asSequence().filter { scriptingSettings.isScriptDefinitionEnabled(it) } + } + + /** + * Property lists all discovered definitions with no [KotlinScriptingSettingsImpl.isScriptDefinitionEnabled] filtering applied. + * If by the moment of the call any of the [ScriptDefinitionsSource]s has not yet contributed to the resulting set of definitions, + * it's called before returning the result. + * @return All discovered definitions. The list is sorted according to the [KotlinScriptingSettingsImpl.getScriptDefinitionOrder] or, + * if the latter is missing, conforms default by-source order (see [ScriptDefinitionsManager]). + * @see [currentDefinitions] + */ + override fun getDefinitions(): List = getOrLoadDefinitions() + + /** + * Searches script definition that best matches the specified [script]. + * Contribution from all [ScriptDefinitionsSource]s in taken into configuration. If any of the sources has not yet provided its + * input by the moment of the method call it's triggered proactively. + */ + override fun findDefinition(script: SourceCode): ScriptDefinition? { + getOrLoadDefinitions() + + val definition = + if (isScratchFile(script)) { + // Scratch should always have the default script definition + getDefaultDefinition() + } else { + super.findDefinition(script) // Some embedded scripts (e.g., Kotlin Notebooks) have their own definition + ?: if (isEmbeddedScript(script)) getDefaultDefinition() else null + } + + return definition + } + + /** + * Goes through the list of registered [ScriptDefinitionsSource]s and triggers definitions reload. + * Result of previous reloads is invalidated including those launched via [reloadDefinitionsBy]. + * @return All discovered definitions. The list is sorted according to the [KotlinScriptingSettingsImpl.getScriptDefinitionOrder] or, + * if the latter is missing, conforms default by-source order (see [ScriptDefinitionsManager]). + */ + fun reloadDefinitions(): List = reloadDefinitionsInternal(getSources()) + + /** + * Reloads definitions from the requested [source] only. Definitions provided by other [ScriptDefinitionsSource]s remain as is + * (could remain unloaded). + * @return All definitions known by the moment the method is complete. + * The list is sorted according to the [KotlinScriptingSettingsImpl.getScriptDefinitionOrder] or, if the latter is missing, conforms + * default by-source order (see [ScriptDefinitionsManager]). + */ + fun reloadDefinitionsBy(source: ScriptDefinitionsSource): List = reloadDefinitionsInternal(listOf(source)) + + /** + * Reorders all known definitions according to the [KotlinScriptingSettingsImpl.getScriptDefinitionOrder]. + * + * The method is intended for a narrow range of purposes and should not be used in regular production scenarios. + * Among those purposes are testing, troubleshooting and workaround for the case when some definition is preferred to a desired one. + * + * @return Reordered definitions known by the moment of the method call. + */ + fun reorderDefinitions(): List { + if (definitions == null) return emptyList() + val scriptingSettings = getKotlinScriptingSettings() + + withLocks { + definitions?.let { list -> + list.forEach { + it.order = scriptingSettings.getScriptDefinitionOrder(it) + } + definitions = list.sortedBy(ScriptDefinition::order) + } + clearCache() + } + + applyDefinitionsUpdate() // <== acquires read-action inside + return definitions ?: emptyList() + } + + /** + * @return Definition bundled with IDEA and aimed for basic '.kts' scripts support. + */ + override fun getDefaultDefinition(): ScriptDefinition { + return project.defaultDefinition + } + + // This function is aimed to fix locks acquisition order. + // The internal block still may acquire the read lock, it just won't have an effect. + private fun withLocks(block: () -> Unit) = executeUnderReadLock { definitionsLock.withLock { block.invoke() } } + + private fun getOrLoadDefinitions(): List { + // This is not thread safe, but if the condition changes by the time of the "then do this" it's ok - we just refresh the data. + // Taking local lock here is dangerous due to the possible global read-lock acquisition (hence, the deadlock). See KTIJ-27838. + return if (definitions == null || !allDefinitionSourcesContributedToCache()) { + reloadDefinitionsInternal(getSources()) + } else { + definitions ?: error("'definitions' became null after they weren't") + } + } + + private fun allDefinitionSourcesContributedToCache(): Boolean = activatedDefinitionSources.containsAll(getSources()) + + private fun reloadDefinitionsInternal(sources: List): List { + var loadedDefinitions: List? = null + + val (ms, newDefinitionsBySource) = measureTimeMillisWithResult { + sources.associateWith { + val (ms, definitions) = measureTimeMillisWithResult { it.safeGetDefinitions() /* can acquire read-action inside */ } + scriptingDebugLog { "Loaded definitions: time = $ms ms, source = ${it.javaClass.name}, definitions = ${definitions.map { it.name }}" } + definitions + } + } + + scriptingDebugLog { "Definitions loading total time: $ms ms" } + + val scriptingSettings = getKotlinScriptingSettings() + + withLocks { + if (definitionsBySource.isEmpty()) { + // Keeping definition sources' order is a crucial contract. + // Here we initialize our preserve-insertion-order-map with all known sources-in-desired-order. + // Values are updated later accordingly. + getSources().forEach { definitionsBySource[it] = emptyList() } + } + + definitionsBySource.putAll(newDefinitionsBySource) + + loadedDefinitions = definitionsBySource.values.flattenTo(mutableListOf()) + .onEach { it.order = scriptingSettings.getScriptDefinitionOrder(it) } + .sortedBy(ScriptDefinition::order) + .takeIf { it.isNotEmpty() } + + definitions = loadedDefinitions + clearCache() + } + + activatedDefinitionSources.addAll(sources) + + applyDefinitionsUpdate() // <== acquires read-action inside + + return loadedDefinitions ?: emptyList() + } + + private fun ScriptDefinitionsSource.safeGetDefinitions(): List { + if (!failedContributorsHashes.contains(hashCode())) try { + return definitions.toList() + } catch (t: Throwable) { + if (t is ControlFlowException) throw t + // reporting failed loading only once + failedContributorsHashes.add(hashCode()) + scriptingErrorLog("Cannot load script definitions from $this: ${t.cause?.message ?: t.message}", t) + } + return emptyList() + } + + private fun isEmbeddedScript(code: SourceCode): Boolean { + val scriptSource = code as? VirtualFileScriptSource ?: return false + val virtualFile = scriptSource.virtualFile + return virtualFile is VirtualFileWindow && virtualFile.fileType == KotlinFileType.INSTANCE + } + + private fun associateFileExtensionsIfNeeded() { + val fileTypeManager = FileTypeManager.getInstance() + val newExtensions = getKnownFilenameExtensions().toSet().filter { + val fileTypeByExtension = fileTypeManager.getFileTypeByFileName("xxx.$it") + val notKnown = fileTypeByExtension != KotlinFileType.INSTANCE + if (notKnown) { + scriptingWarnLog("extension $it file type [${fileTypeByExtension.name}] is not registered as ${KotlinFileType.INSTANCE.name}") + } + notKnown + }.toSet() + + if (newExtensions.isNotEmpty()) { + scriptingWarnLog("extensions ${newExtensions} is about to be registered as ${KotlinFileType.INSTANCE.name}") + // Register new file extensions + ApplicationManager.getApplication().invokeLater { + runWriteAction { + newExtensions.forEach { + fileTypeManager.associateExtension(KotlinFileType.INSTANCE, it) + } + } + } + } + } + + override fun dispose() { + super.dispose() + + clearCache() + + definitionsBySource.clear() + definitions = null + activatedDefinitionSources.clear() + failedContributorsHashes.clear() + } + + // FOR TESTS ONLY: we introduce a possibility to cut dependencies over inheritance + + protected open fun getSources(): List { + val fromNewEp = SCRIPT_DEFINITIONS_SOURCES.getExtensions(project) + return fromNewEp.dropLast(1) + fromNewEp.last() + } + + protected open fun getKotlinScriptingSettings(): KotlinScriptingSettings = KotlinScriptingSettings.getInstance(project) + + protected open fun applyDefinitionsUpdate() { + associateFileExtensionsIfNeeded() + ScriptDependencyAware.getInstance(project).updateScriptDefinitionReferences() + } + + protected open fun isScratchFile(script: SourceCode): Boolean { + val virtualFile = + if (script is VirtualFileScriptSource) script.virtualFile + else script.locationId?.let { VirtualFileManager.getInstance().findFileByUrl(it) } + return virtualFile != null && ScratchFileService.getInstance().getRootType(virtualFile) is ScratchRootType + } + + protected open fun executeUnderReadLock(block: () -> Unit) = runReadAction { block() } + + // FOR TESTS ONLY: END +} \ No newline at end of file diff --git a/plugins/kotlin/base/scripting/src/org/jetbrains/kotlin/idea/core/script/ScriptDependencyAware.kt b/plugins/kotlin/base/scripting/src/org/jetbrains/kotlin/idea/core/script/ScriptDependencyAware.kt index 268f907d42be..92ee36febb4e 100644 --- a/plugins/kotlin/base/scripting/src/org/jetbrains/kotlin/idea/core/script/ScriptDependencyAware.kt +++ b/plugins/kotlin/base/scripting/src/org/jetbrains/kotlin/idea/core/script/ScriptDependencyAware.kt @@ -23,6 +23,8 @@ interface ScriptDependencyAware { fun getScriptDependingOn(dependencies: Collection): VirtualFile? + fun updateScriptDefinitionReferences(): Unit = Unit + companion object { fun getInstance(project: Project): ScriptDependencyAware = project.service() as ScriptDependencyAware diff --git a/plugins/kotlin/base/scripting/src/org/jetbrains/kotlin/idea/core/script/settings/KotlinScriptingSettings.kt b/plugins/kotlin/base/scripting/src/org/jetbrains/kotlin/idea/core/script/settings/KotlinScriptingSettings.kt index dc9037c29c63..1670a4459355 100644 --- a/plugins/kotlin/base/scripting/src/org/jetbrains/kotlin/idea/core/script/settings/KotlinScriptingSettings.kt +++ b/plugins/kotlin/base/scripting/src/org/jetbrains/kotlin/idea/core/script/settings/KotlinScriptingSettings.kt @@ -8,6 +8,9 @@ import org.jetbrains.kotlin.scripting.definitions.ScriptDefinition interface KotlinScriptingSettings { fun autoReloadConfigurations(scriptDefinition: ScriptDefinition): Boolean fun setAutoReloadConfigurations(scriptDefinition: ScriptDefinition, autoReloadScriptDependencies: Boolean) + fun setOrder(scriptDefinition: ScriptDefinition, order: Int) + fun isScriptDefinitionEnabled(scriptDefinition: ScriptDefinition): Boolean + fun getScriptDefinitionOrder(scriptDefinition: ScriptDefinition): Int companion object { fun getInstance(project: Project): KotlinScriptingSettings = project.service()