diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ca80aa0cf2..d107a6d57b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] kotlin = "1.8.21" +byteBuddy = "1.15.10" caffeine = "3.1.8" commons-io = "2.16.1" gson = "2.10.1" @@ -22,6 +23,7 @@ spullara-cliParser = "1.1.6" xz = "1.9" [libraries] +byteBuddy = { group = "net.bytebuddy", name = "byte-buddy", version.ref = "byteBuddy" } caffeine = { group = "com.github.ben-manes.caffeine", name = "caffeine", version.ref = "caffeine" } commons-io = { group = "commons-io", name = "commons-io", version.ref = "commons-io" } gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } diff --git a/intellij-plugin-structure/structure-base/src/main/kotlin/com/jetbrains/plugin/structure/base/utils/contentBuilder/ContentBuilder.kt b/intellij-plugin-structure/structure-base/src/main/kotlin/com/jetbrains/plugin/structure/base/utils/contentBuilder/ContentBuilder.kt index 9d303d84c0..fc9b771142 100644 --- a/intellij-plugin-structure/structure-base/src/main/kotlin/com/jetbrains/plugin/structure/base/utils/contentBuilder/ContentBuilder.kt +++ b/intellij-plugin-structure/structure-base/src/main/kotlin/com/jetbrains/plugin/structure/base/utils/contentBuilder/ContentBuilder.kt @@ -4,8 +4,12 @@ package com.jetbrains.plugin.structure.base.utils.contentBuilder -import com.jetbrains.plugin.structure.base.utils.* -import java.io.File +import com.jetbrains.plugin.structure.base.utils.createDir +import com.jetbrains.plugin.structure.base.utils.isDirectory +import com.jetbrains.plugin.structure.base.utils.isFile +import com.jetbrains.plugin.structure.base.utils.listFiles +import com.jetbrains.plugin.structure.base.utils.readBytes +import com.jetbrains.plugin.structure.base.utils.simpleName import java.nio.file.Path interface ContentBuilder { @@ -16,6 +20,7 @@ interface ContentBuilder { fun file(name: String, localFile: Path) fun dir(name: String, localDirectory: Path) fun dir(name: String, content: ContentBuilder.() -> Unit) + fun dirs(name: String, content: ContentBuilder.() -> Unit) fun zip(name: String, content: ContentBuilder.() -> Unit) } @@ -69,6 +74,23 @@ private class ContentBuilderImpl(private val result: ChildrenOwnerSpec) : Conten addChild(name, directorySpec) } + override fun dirs(name: String, content: ContentBuilder.() -> Unit) { + val pathElements = name.split("/") + when (pathElements.size) { + 0 -> throw IllegalArgumentException("Cannot have empty name") + 1 -> dir(pathElements.first(), content) + 2 -> dir(pathElements.first()) { + dir(pathElements[1], content) + } + + else -> { + val firstElement = pathElements.first() + val rest = pathElements.drop(1) + addChild(firstElement, resolveDirs(rest, content)) + } + } + } + override fun dir(name: String, localDirectory: Path) { check(localDirectory.isDirectory) { "Not a directory: $localDirectory" } dir(name) { @@ -90,4 +112,40 @@ private class ContentBuilderImpl(private val result: ChildrenOwnerSpec) : Conten private fun addChild(name: String, spec: ContentSpec) { result.addChild(name, spec) } + + private fun resolveDirs(pathElements: List, content: ContentBuilder.() -> Unit): SingleChildSpec { + val parentAndChildPairs = pathElements.windowedPairs() + val directorySpecs = parentAndChildPairs.mapIndexed { i, (parent, child) -> + val childContent = if (i < parentAndChildPairs.lastIndex) DirectorySpec() else buildDirectoryContent(content) + SingleChildSpec(parent, child, childContent) + } + return buildHierarchy(directorySpecs) + } + + private fun buildHierarchy(specs: List): SingleChildSpec = with(ArrayDeque(specs)) { + while (size > 1) { + val child = removeLast() + val parent = removeLast() + addLast(parent.copy(child = child)) + } + first() + } + + private fun List.windowedPairs() = windowed(2) { ParentAndChild(it.first(), it.last()) } + + private data class ParentAndChild(val parent: String, val child: String) + + private data class SingleChildSpec(val name: String, val child: ContentSpec) : ContentSpec { + constructor(name: String, child: String, childContent: ContentSpec) : this(name, SingleChildSpec(child, childContent)) + + override fun generate(target: Path) { + target.createDir() + val childFile = target.resolve(name) + child.generate(childFile) + } + + override fun toString(): String { + return "$name/$child" + } + } } diff --git a/intellij-plugin-structure/structure-base/src/main/kotlin/com/jetbrains/plugin/structure/base/utils/contentBuilder/DirectorySpec.kt b/intellij-plugin-structure/structure-base/src/main/kotlin/com/jetbrains/plugin/structure/base/utils/contentBuilder/DirectorySpec.kt index ab7829a461..bb137e74b1 100644 --- a/intellij-plugin-structure/structure-base/src/main/kotlin/com/jetbrains/plugin/structure/base/utils/contentBuilder/DirectorySpec.kt +++ b/intellij-plugin-structure/structure-base/src/main/kotlin/com/jetbrains/plugin/structure/base/utils/contentBuilder/DirectorySpec.kt @@ -25,4 +25,14 @@ class DirectorySpec : ChildrenOwnerSpec { spec.generate(childFile) } } + + override fun toString(): String { + return children.map { (name, content) -> + if (content is DirectorySpec) { + "$name: $content" + } else { + name + } + }.joinToString(prefix = "[", postfix = "]") + } } \ No newline at end of file diff --git a/intellij-plugin-structure/structure-classes/src/main/java/com/jetbrains/plugin/structure/classes/resolvers/CompositeResolver.kt b/intellij-plugin-structure/structure-classes/src/main/java/com/jetbrains/plugin/structure/classes/resolvers/CompositeResolver.kt index d3b25d639d..91fcebdc01 100644 --- a/intellij-plugin-structure/structure-classes/src/main/java/com/jetbrains/plugin/structure/classes/resolvers/CompositeResolver.kt +++ b/intellij-plugin-structure/structure-classes/src/main/java/com/jetbrains/plugin/structure/classes/resolvers/CompositeResolver.kt @@ -8,12 +8,15 @@ import com.jetbrains.plugin.structure.base.utils.closeAll import org.objectweb.asm.tree.ClassNode import java.util.* + +private const val DEFAULT_COMPOSITE_RESOLVER_NAME = "Unnamed Composite Resolver" /** * [Resolver] that combines several [resolvers] with the Java classpath search strategy. */ class CompositeResolver private constructor( private val resolvers: List, - override val readMode: ReadMode + override val readMode: ReadMode, + val name: String ) : Resolver() { private val packageToResolvers: MutableMap> = hashMapOf() @@ -93,7 +96,7 @@ class CompositeResolver private constructor( resolvers.closeAll() } - override fun toString() = "Union of ${resolvers.size} resolver" + (if (resolvers.size != 1) "s" else "") + override fun toString() = "$name is a union of ${resolvers.size} resolver" + (if (resolvers.size != 1) "s" else "") companion object { @@ -102,9 +105,14 @@ class CompositeResolver private constructor( @JvmStatic fun create(resolvers: Iterable): Resolver { + return create(resolvers, DEFAULT_COMPOSITE_RESOLVER_NAME) + } + + @JvmStatic + fun create(resolvers: Iterable, resolverName: String): Resolver { val list = resolvers.toList() return when(list.size) { - 0 -> EmptyResolver + 0 -> EmptyNamedResolver(resolverName) 1 -> list.first() else -> { val readMode = if (list.all { it.readMode == ReadMode.FULL }) { @@ -112,7 +120,7 @@ class CompositeResolver private constructor( } else { ReadMode.SIGNATURES } - CompositeResolver(list, readMode) + CompositeResolver(list, readMode, resolverName) } } } diff --git a/intellij-plugin-structure/structure-classes/src/main/java/com/jetbrains/plugin/structure/classes/resolvers/EmptyNamedResolver.kt b/intellij-plugin-structure/structure-classes/src/main/java/com/jetbrains/plugin/structure/classes/resolvers/EmptyNamedResolver.kt new file mode 100644 index 0000000000..7ca9c0d5e9 --- /dev/null +++ b/intellij-plugin-structure/structure-classes/src/main/java/com/jetbrains/plugin/structure/classes/resolvers/EmptyNamedResolver.kt @@ -0,0 +1,29 @@ +package com.jetbrains.plugin.structure.classes.resolvers + +import org.objectweb.asm.tree.ClassNode +import java.util.* + +class EmptyNamedResolver(val name: String) : Resolver() { + override val readMode + get() = ReadMode.FULL + + override fun processAllClasses(processor: (ResolutionResult) -> Boolean) = true + + override fun resolveClass(className: String) = ResolutionResult.NotFound + + override fun resolveExactPropertyResourceBundle(baseName: String, locale: Locale) = ResolutionResult.NotFound + + override fun containsClass(className: String) = false + + override fun containsPackage(packageName: String) = false + + override val allClasses get() = emptySet() + + override val allPackages get() = emptySet() + + override val allBundleNameSet: ResourceBundleNameSet get() = ResourceBundleNameSet(emptyMap()) + + override fun toString() = "$name (empty resolver)" + + override fun close() = Unit +} diff --git a/intellij-plugin-structure/structure-ide-classes/src/main/java/com/jetbrains/plugin/structure/ide/classes/IdeResolverCreator.kt b/intellij-plugin-structure/structure-ide-classes/src/main/java/com/jetbrains/plugin/structure/ide/classes/IdeResolverCreator.kt index aa2f1e8439..a5b96fd969 100644 --- a/intellij-plugin-structure/structure-ide-classes/src/main/java/com/jetbrains/plugin/structure/ide/classes/IdeResolverCreator.kt +++ b/intellij-plugin-structure/structure-ide-classes/src/main/java/com/jetbrains/plugin/structure/ide/classes/IdeResolverCreator.kt @@ -21,6 +21,7 @@ import com.jetbrains.plugin.structure.ide.IdeManagerImpl.Companion.isCompiledCom import com.jetbrains.plugin.structure.ide.IdeManagerImpl.Companion.isCompiledUltimate import com.jetbrains.plugin.structure.ide.IdeManagerImpl.Companion.isDistributionIde import com.jetbrains.plugin.structure.ide.InvalidIdeException +import com.jetbrains.plugin.structure.ide.classes.resolver.ProductInfoClassResolver import com.jetbrains.plugin.structure.ide.getRepositoryLibrariesJars import java.nio.file.Path @@ -33,7 +34,7 @@ object IdeResolverCreator { fun createIdeResolver(readMode: Resolver.ReadMode, ide: Ide): Resolver { val idePath = ide.idePath return when { - isDistributionIde(idePath) -> getJarsResolver(idePath.resolve("lib"), readMode, IdeFileOrigin.IdeLibDirectory(ide)) + isDistributionIde(idePath) -> getIdeResolverFromDistribution(ide, readMode) isCompiledCommunity(idePath) || isCompiledUltimate(idePath) -> getIdeResolverFromCompiledSources(idePath, readMode, ide) else -> throw InvalidIdeException(idePath, "Invalid IDE $ide at $idePath") } @@ -54,6 +55,14 @@ object IdeResolverCreator { return CompositeResolver.create(buildJarOrZipFileResolvers(jars + antJars + moduleJars, readMode, parentOrigin)) } + private fun getIdeResolverFromDistribution(ide: Ide, readMode: Resolver.ReadMode): Resolver = with(ide) { + return if (ProductInfoClassResolver.supports(idePath)) { + ProductInfoClassResolver.of(ide, readMode) + } else { + getJarsResolver(idePath.resolve("lib"), readMode, IdeFileOrigin.IdeLibDirectory(ide)) + } + } + //TODO: Resolver created this way contains all libraries declared in the project, // including those that don't go to IDE distribution. So such a created resolver may // resolve classes differently than they are resolved when running IDE. diff --git a/intellij-plugin-structure/structure-ide-classes/src/main/java/com/jetbrains/plugin/structure/ide/classes/resolver/NamedResolver.kt b/intellij-plugin-structure/structure-ide-classes/src/main/java/com/jetbrains/plugin/structure/ide/classes/resolver/NamedResolver.kt new file mode 100644 index 0000000000..b5503eb9b0 --- /dev/null +++ b/intellij-plugin-structure/structure-ide-classes/src/main/java/com/jetbrains/plugin/structure/ide/classes/resolver/NamedResolver.kt @@ -0,0 +1,31 @@ +package com.jetbrains.plugin.structure.ide.classes.resolver + +import com.jetbrains.plugin.structure.classes.resolvers.ResolutionResult +import com.jetbrains.plugin.structure.classes.resolvers.Resolver +import org.objectweb.asm.tree.ClassNode +import java.util.* + +class NamedResolver(val name: String, val delegateResolver: Resolver) : Resolver() { + + override val readMode get() = delegateResolver.readMode + + override val allClasses get() = delegateResolver.allClasses + + override val allPackages get() = delegateResolver.allPackages + + override val allBundleNameSet get() = delegateResolver.allBundleNameSet + + override fun resolveClass(className: String) = delegateResolver.resolveClass(className) + + override fun resolveExactPropertyResourceBundle(baseName: String, locale: Locale) = + delegateResolver.resolveExactPropertyResourceBundle(baseName, locale) + + override fun containsClass(className: String) = delegateResolver.containsClass(className) + + override fun containsPackage(packageName: String) = delegateResolver.containsPackage(packageName) + + override fun processAllClasses(processor: (ResolutionResult) -> Boolean) = + delegateResolver.processAllClasses(processor) + + override fun close() = delegateResolver.close() +} \ No newline at end of file diff --git a/intellij-plugin-structure/structure-ide-classes/src/main/java/com/jetbrains/plugin/structure/ide/classes/resolver/PluginDependencyFilteredResolver.kt b/intellij-plugin-structure/structure-ide-classes/src/main/java/com/jetbrains/plugin/structure/ide/classes/resolver/PluginDependencyFilteredResolver.kt new file mode 100644 index 0000000000..913190fff0 --- /dev/null +++ b/intellij-plugin-structure/structure-ide-classes/src/main/java/com/jetbrains/plugin/structure/ide/classes/resolver/PluginDependencyFilteredResolver.kt @@ -0,0 +1,62 @@ +package com.jetbrains.plugin.structure.ide.classes.resolver + +import com.jetbrains.plugin.structure.classes.resolvers.CompositeResolver +import com.jetbrains.plugin.structure.classes.resolvers.ResolutionResult +import com.jetbrains.plugin.structure.classes.resolvers.Resolver +import com.jetbrains.plugin.structure.intellij.plugin.IdePlugin +import com.jetbrains.plugin.structure.intellij.plugin.dependencies.DefaultDependenciesProvider +import com.jetbrains.plugin.structure.intellij.plugin.dependencies.DependenciesProvider +import org.objectweb.asm.tree.ClassNode +import java.util.* + +/** + * A classpath resolver that matches plugin dependencies against bundled plugins of an `product-info.json`-based IDE. + * + * This resolver functions as a classpath filter. + * It takes classes and resource bundles from the IDE + * and includes only those that are declared as dependencies in the plugin. + */ +class PluginDependencyFilteredResolver( + plugin: IdePlugin, + productInfoClassResolver: ProductInfoClassResolver, + private val dependenciesProvider: DependenciesProvider = DefaultDependenciesProvider(productInfoClassResolver.ide) +) : Resolver() { + val filteredResolvers: List = getResolvers(plugin, productInfoClassResolver) + + private fun getResolvers(plugin: IdePlugin, productInfoClassResolver: ProductInfoClassResolver): List { + return dependenciesProvider + .getDependencies(plugin) + .map { dependency -> + productInfoClassResolver.layoutComponentResolvers.firstOrNull { component -> dependency.matches(component.name) } + ?: productInfoClassResolver.bootClasspathResolver + } + } + + private val delegateResolver = filteredResolvers.asResolver() + + override val readMode get() = delegateResolver.readMode + + override val allClasses get() = delegateResolver.allClasses + + override val allPackages get() = delegateResolver.allPackages + + override val allBundleNameSet get() = delegateResolver.allBundleNameSet + + override fun resolveClass(className: String) = delegateResolver.resolveClass(className) + + override fun resolveExactPropertyResourceBundle(baseName: String, locale: Locale) = + delegateResolver.resolveExactPropertyResourceBundle(baseName, locale) + + override fun containsClass(className: String) = delegateResolver.containsClass(className) + + override fun containsPackage(packageName: String) = delegateResolver.containsPackage(packageName) + + override fun processAllClasses(processor: (ResolutionResult) -> Boolean) = + delegateResolver.processAllClasses(processor) + + override fun close() = delegateResolver.close() + + private fun List.asResolver(): Resolver { + return CompositeResolver.create(this) + } +} \ No newline at end of file diff --git a/intellij-plugin-structure/structure-ide-classes/src/main/java/com/jetbrains/plugin/structure/ide/classes/resolver/ProductInfoClassResolver.kt b/intellij-plugin-structure/structure-ide-classes/src/main/java/com/jetbrains/plugin/structure/ide/classes/resolver/ProductInfoClassResolver.kt new file mode 100644 index 0000000000..2cd947ac16 --- /dev/null +++ b/intellij-plugin-structure/structure-ide-classes/src/main/java/com/jetbrains/plugin/structure/ide/classes/resolver/ProductInfoClassResolver.kt @@ -0,0 +1,139 @@ +package com.jetbrains.plugin.structure.ide.classes.resolver + +import com.jetbrains.plugin.structure.base.utils.exists +import com.jetbrains.plugin.structure.classes.resolvers.CompositeResolver +import com.jetbrains.plugin.structure.classes.resolvers.EmptyResolver +import com.jetbrains.plugin.structure.classes.resolvers.JarFileResolver +import com.jetbrains.plugin.structure.classes.resolvers.ResolutionResult +import com.jetbrains.plugin.structure.classes.resolvers.Resolver +import com.jetbrains.plugin.structure.classes.resolvers.Resolver.ReadMode.FULL +import com.jetbrains.plugin.structure.ide.BuildTxtIdeVersionProvider +import com.jetbrains.plugin.structure.ide.Ide +import com.jetbrains.plugin.structure.ide.IdeVersionResolution +import com.jetbrains.plugin.structure.ide.InvalidIdeException +import com.jetbrains.plugin.structure.ide.classes.IdeFileOrigin.IdeLibDirectory +import com.jetbrains.plugin.structure.intellij.platform.LayoutComponent +import com.jetbrains.plugin.structure.intellij.platform.ProductInfo +import com.jetbrains.plugin.structure.intellij.platform.ProductInfoParseException +import com.jetbrains.plugin.structure.intellij.platform.ProductInfoParser +import com.jetbrains.plugin.structure.intellij.version.IdeVersion +import org.objectweb.asm.tree.ClassNode +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.nio.file.Path +import java.util.* + +private val LOG: Logger = LoggerFactory.getLogger(ProductInfoClassResolver::class.java) + +private const val PRODUCT_INFO_JSON = "product-info.json" + +class ProductInfoClassResolver( + private val productInfo: ProductInfo, val ide: Ide, override val readMode: ReadMode = FULL +) : Resolver() { + + val layoutComponentResolvers: List = productInfo.layout + .mapNotNull(::getResourceResolver) + + private val delegateResolver = getDelegateResolver() + + private fun getDelegateResolver(): Resolver = mutableListOf().apply { + add(bootClasspathResolver) + addAll(layoutComponentResolvers) + }.asResolver() + + private fun getResourceResolver(layoutComponent: LayoutComponent): NamedResolver? { + return if (layoutComponent is LayoutComponent.Classpathable) { + getClasspathableResolver(layoutComponent) + } else { + LOG.atDebug().log("No classpath declared for '{}'. Skipping", layoutComponent) + null + } + } + + override val allClasses get() = delegateResolver.allClasses + + override val allPackages get() = delegateResolver.allPackages + + override val allBundleNameSet get() = delegateResolver.allBundleNameSet + + override fun resolveClass(className: String) = delegateResolver.resolveClass(className) + + override fun resolveExactPropertyResourceBundle(baseName: String, locale: Locale) = + delegateResolver.resolveExactPropertyResourceBundle(baseName, locale) + + override fun containsClass(className: String) = delegateResolver.containsClass(className) + + override fun containsPackage(packageName: String) = delegateResolver.containsPackage(packageName) + + override fun processAllClasses(processor: (ResolutionResult) -> Boolean) = + delegateResolver.processAllClasses(processor) + + override fun close() = delegateResolver.close() + + val bootClasspathResolver: NamedResolver + get() { + val bootJars = productInfo.launches.firstOrNull()?.bootClassPathJarNames + val bootResolver = bootJars?.map { getBootJarResolver(it) }?.asResolver() + ?: EmptyResolver + return NamedResolver("bootClassPathJarNames", bootResolver) + } + + private fun getClasspathableResolver(layoutComponent: C): NamedResolver + where C : LayoutComponent.Classpathable, C : LayoutComponent { + val itemJarResolvers = layoutComponent.getClasspath().map { jarPath: Path -> + val fullyQualifiedJarFile = ide.idePath.resolve(jarPath) + NamedResolver( + layoutComponent.name + "#" + jarPath, + JarFileResolver(fullyQualifiedJarFile, readMode, IdeLibDirectory(ide)) + ) + } + return NamedResolver(layoutComponent.name, CompositeResolver.create(itemJarResolvers, layoutComponent.name)) + } + + private fun getBootJarResolver(relativeJarPath: String): NamedResolver { + val fullyQualifiedJarFile = ide.idePath.resolve("lib/$relativeJarPath") + return NamedResolver(relativeJarPath, JarFileResolver(fullyQualifiedJarFile, readMode, IdeLibDirectory(ide))) + } + + private fun List.asResolver() = CompositeResolver.create(this) + + companion object { + @Throws(InvalidIdeException::class) + fun of(ide: Ide, readMode: ReadMode = FULL): ProductInfoClassResolver { + val idePath = ide.idePath + assertProductInfoPresent(idePath) + val productInfoParser = ProductInfoParser() + try { + val productInfo = productInfoParser.parse(idePath.productInfoJson) + return ProductInfoClassResolver(productInfo, ide, readMode) + } catch (e: ProductInfoParseException) { + throw InvalidIdeException(idePath, e) + } + } + + @Throws(InvalidIdeException::class) + private fun assertProductInfoPresent(idePath: Path) { + if (!idePath.containsProductInfoJson()) { + throw InvalidIdeException(idePath, "The '${PRODUCT_INFO_JSON}' file is not available.") + } + } + + fun supports(idePath: Path): Boolean = idePath.containsProductInfoJson() + && isAtLeastVersion(idePath, "242") + + private fun isAtLeastVersion(idePath: Path, expectedVersion: String): Boolean { + return when (val version = BuildTxtIdeVersionProvider().readIdeVersion(idePath)) { + is IdeVersionResolution.Found -> version.ideVersion > IdeVersion.createIdeVersion(expectedVersion) + is IdeVersionResolution.Failed, + is IdeVersionResolution.NotFound -> false + } + } + + private fun Path.containsProductInfoJson(): Boolean = resolve(PRODUCT_INFO_JSON).exists() + + private val Path.productInfoJson: Path + get() { + return resolve(PRODUCT_INFO_JSON) + } + } +} \ No newline at end of file diff --git a/intellij-plugin-structure/structure-ide/src/main/java/com/jetbrains/plugin/structure/ide/ProductInfoBasedIde.kt b/intellij-plugin-structure/structure-ide/src/main/java/com/jetbrains/plugin/structure/ide/ProductInfoBasedIde.kt new file mode 100644 index 0000000000..9402844e14 --- /dev/null +++ b/intellij-plugin-structure/structure-ide/src/main/java/com/jetbrains/plugin/structure/ide/ProductInfoBasedIde.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2000-2024 JetBrains s.r.o. and other contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package com.jetbrains.plugin.structure.ide + +import com.jetbrains.plugin.structure.intellij.platform.ProductInfo +import com.jetbrains.plugin.structure.intellij.plugin.IdePlugin +import com.jetbrains.plugin.structure.intellij.version.IdeVersion +import java.nio.file.Path + +class ProductInfoBasedIde( + private val idePath: Path, + private val version: IdeVersion, + private val bundledPlugins: List, + val productInfo: ProductInfo +) : Ide() { + override fun getIdePath() = idePath + + override fun getVersion() = version + + override fun getBundledPlugins() = bundledPlugins +} \ No newline at end of file diff --git a/intellij-plugin-structure/structure-ide/src/main/java/com/jetbrains/plugin/structure/ide/ProductInfoBasedIdeManager.kt b/intellij-plugin-structure/structure-ide/src/main/java/com/jetbrains/plugin/structure/ide/ProductInfoBasedIdeManager.kt index 64260b5612..0679354f98 100644 --- a/intellij-plugin-structure/structure-ide/src/main/java/com/jetbrains/plugin/structure/ide/ProductInfoBasedIdeManager.kt +++ b/intellij-plugin-structure/structure-ide/src/main/java/com/jetbrains/plugin/structure/ide/ProductInfoBasedIdeManager.kt @@ -14,6 +14,7 @@ import com.jetbrains.plugin.structure.ide.layout.PluginWithArtifactPathResult.Co import com.jetbrains.plugin.structure.ide.layout.PluginWithArtifactPathResult.Failure import com.jetbrains.plugin.structure.ide.layout.PluginWithArtifactPathResult.Success import com.jetbrains.plugin.structure.ide.layout.ProductInfoClasspathProvider +import com.jetbrains.plugin.structure.ide.resolver.ProductInfoResourceResolver import com.jetbrains.plugin.structure.intellij.platform.BundledModulesManager import com.jetbrains.plugin.structure.intellij.platform.BundledModulesResolver import com.jetbrains.plugin.structure.intellij.platform.LayoutComponent @@ -22,12 +23,9 @@ import com.jetbrains.plugin.structure.intellij.platform.ProductInfoParseExceptio import com.jetbrains.plugin.structure.intellij.platform.ProductInfoParser import com.jetbrains.plugin.structure.intellij.plugin.IdePlugin import com.jetbrains.plugin.structure.intellij.plugin.IdePluginManager -import com.jetbrains.plugin.structure.intellij.plugin.JarFilesResourceResolver import com.jetbrains.plugin.structure.intellij.problems.IntelliJPluginCreationResultResolver import com.jetbrains.plugin.structure.intellij.problems.JetBrainsPluginCreationResultResolver import com.jetbrains.plugin.structure.intellij.problems.PluginCreationResultResolver -import com.jetbrains.plugin.structure.intellij.resources.CompositeResourceResolver -import com.jetbrains.plugin.structure.intellij.resources.NamedResourceResolver import com.jetbrains.plugin.structure.intellij.resources.ResourceResolver import com.jetbrains.plugin.structure.intellij.version.IdeVersion import com.jetbrains.plugin.structure.jar.PLUGIN_XML @@ -70,7 +68,7 @@ class ProductInfoBasedIdeManager(private val excludeMissingProductInfoLayoutComp } val corePlugin = readCorePlugin(idePath, ideVersion) val plugins = readPlugins(idePath, productInfo, ideVersion) - return IdeImpl(idePath, ideVersion, corePlugin + plugins) + return ProductInfoBasedIde(idePath, ideVersion, corePlugin + plugins, productInfo) } private fun readPlugins( @@ -79,17 +77,13 @@ class ProductInfoBasedIdeManager(private val excludeMissingProductInfoLayoutComp ideVersion: IdeVersion ): List { - val layoutComponents = getLayoutComponents(idePath, productInfo) - val platformResourceResolver = getPlatformResourceResolver(layoutComponents) - + val platformResourceResolver = ProductInfoResourceResolver(productInfo, idePath, excludeMissingProductInfoLayoutComponents) val moduleManager = BundledModulesManager(BundledModulesResolver(idePath)) val moduleV2Factory = ModuleFactory(::createModule, ProductInfoClasspathProvider(productInfo)) val pluginFactory = PluginFactory(::createPlugin) - val moduleLoadingResults = layoutComponents - .map { it.layoutComponent } - .mapNotNull { layoutComponent -> + val moduleLoadingResults = productInfo.layout.mapNotNull { layoutComponent -> when (layoutComponent) { is LayoutComponent.ModuleV2, is LayoutComponent.ProductModuleV2 -> { @@ -117,29 +111,6 @@ class ProductInfoBasedIdeManager(private val excludeMissingProductInfoLayoutComp return corePluginManager.loadCorePlugins(idePath, ideVersion) } - private fun getLayoutComponents(idePath: Path, productInfo: ProductInfo): LayoutComponents { - val layoutComponents = LayoutComponents.of(idePath, productInfo) - return if (excludeMissingProductInfoLayoutComponents) { - val (okComponents, failedComponents) = layoutComponents.partition { it.allClasspathsExist() } - logUnavailableClasspath(failedComponents) - LayoutComponents(okComponents) - } else { - layoutComponents - } - } - - private fun getPlatformResourceResolver(layoutComponents: LayoutComponents): CompositeResourceResolver { - val resourceResolvers = layoutComponents.mapNotNull { - if (it.isClasspathable) { - getResourceResolver(it) - } else { - LOG.atDebug().log("No classpath declared for '{}'. Skipping", it.layoutComponent) - null - } - } - return CompositeResourceResolver(resourceResolvers) - } - private fun createModule( pluginArtifactPath: Path, descriptorName: String, @@ -172,26 +143,6 @@ class ProductInfoBasedIdeManager(private val excludeMissingProductInfoLayoutComp return IdeVersion.createIdeVersion(versionString) } - private fun getResourceResolver(layoutComponent: ResolvedLayoutComponent): NamedResourceResolver? { - if (!layoutComponent.isClasspathable) { - return null - } - val itemJarResolvers = layoutComponent.resolveClasspaths().map { - NamedResourceResolver( - layoutComponent.name + "#" + it.relativePath, JarFilesResourceResolver(it.toList()) - ) - } - return NamedResourceResolver(layoutComponent.name, CompositeResourceResolver(itemJarResolvers)) - } - - private fun logUnavailableClasspath(failedComponents: List) { - val logMsg = failedComponents.joinToString("\n") { - val cp = it.getClasspaths().joinToString(", ") - "Layout component '${it.name}' has some nonexistent 'classPath' elements: '$cp'" - } - LOG.atWarn().log(logMsg) - } - private fun Path.containsProductInfoJson(): Boolean = resolve(PRODUCT_INFO_JSON).exists() private val Path.productInfoJson: Path diff --git a/intellij-plugin-structure/structure-ide/src/main/java/com/jetbrains/plugin/structure/ide/resolver/ProductInfoResourceResolver.kt b/intellij-plugin-structure/structure-ide/src/main/java/com/jetbrains/plugin/structure/ide/resolver/ProductInfoResourceResolver.kt new file mode 100644 index 0000000000..9f2d2a8931 --- /dev/null +++ b/intellij-plugin-structure/structure-ide/src/main/java/com/jetbrains/plugin/structure/ide/resolver/ProductInfoResourceResolver.kt @@ -0,0 +1,118 @@ +package com.jetbrains.plugin.structure.ide.resolver + +import com.jetbrains.plugin.structure.base.utils.exists +import com.jetbrains.plugin.structure.intellij.platform.LayoutComponent +import com.jetbrains.plugin.structure.intellij.platform.ProductInfo +import com.jetbrains.plugin.structure.intellij.plugin.JarFilesResourceResolver +import com.jetbrains.plugin.structure.intellij.resources.CompositeResourceResolver +import com.jetbrains.plugin.structure.intellij.resources.NamedResourceResolver +import com.jetbrains.plugin.structure.intellij.resources.ResourceResolver +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.nio.file.Path + +private val LOG: Logger = LoggerFactory.getLogger(ProductInfoResourceResolver::class.java) + +class ProductInfoResourceResolver( + productInfo: ProductInfo, + idePath: Path, + private val excludeMissingProductInfoLayoutComponents: Boolean = true +) : ResourceResolver { + + private val delegateResolver = getPlatformResourceResolver(resolveLayoutComponents(productInfo, idePath)) + + private fun getPlatformResourceResolver(layoutComponents: LayoutComponents): CompositeResourceResolver { + val resourceResolvers = layoutComponents.mapNotNull { + if (it.isClasspathable) { + getResourceResolver(it) + } else { + LOG.atDebug().log("No classpath declared for '{}'. Skipping", it.layoutComponent) + null + } + } + return CompositeResourceResolver(resourceResolvers) + } + private fun resolveLayoutComponents(productInfo: ProductInfo, idePath: Path): LayoutComponents { + val layoutComponents = LayoutComponents.of(idePath, productInfo) + return if (excludeMissingProductInfoLayoutComponents) { + val (okComponents, failedComponents) = layoutComponents.partition { it.allClasspathsExist() } + logUnavailableClasspath(failedComponents) + LayoutComponents(okComponents) + } else { + layoutComponents + } + } + + private fun getResourceResolver(layoutComponent: ResolvedLayoutComponent): NamedResourceResolver? { + if (!layoutComponent.isClasspathable) { + return null + } + val itemJarResolvers = layoutComponent.resolveClasspaths().map { + NamedResourceResolver( + layoutComponent.name + "#" + it.relativePath, JarFilesResourceResolver(it.toList()) + ) + } + return NamedResourceResolver(layoutComponent.name, CompositeResourceResolver(itemJarResolvers)) + } + + override fun resolveResource(relativePath: String, basePath: Path): ResourceResolver.Result = + delegateResolver.resolveResource(relativePath, basePath) + + private fun logUnavailableClasspath(failedComponents: List) { + val logMsg = failedComponents.joinToString("\n") { + val cp = it.getClasspaths().joinToString(", ") + "Layout component '${it.name}' has some nonexistent 'classPath' elements: '$cp'" + } + LOG.atWarn().log(logMsg) + } + + private data class IdeRelativePath(val idePath: Path, val relativePath: Path) { + val resolvedPath: Path? = idePath.resolve(relativePath) + + val exists: Boolean + get() = resolvedPath?.exists() ?: false + + fun toList(): List = if (resolvedPath != null) listOf(resolvedPath) else emptyList() + } + + private data class ResolvedLayoutComponent(val idePath: Path, val layoutComponent: LayoutComponent) { + val name: String + get() = layoutComponent.name + + fun getClasspaths(): List { + return if (layoutComponent is LayoutComponent.Classpathable) { + layoutComponent.getClasspath() + } else { + emptyList() + } + } + + fun resolveClasspaths(): List { + return if (layoutComponent is LayoutComponent.Classpathable) { + layoutComponent.getClasspath().map { IdeRelativePath(idePath, it) } + } else { + emptyList() + } + } + + fun allClasspathsExist(): Boolean { + return resolveClasspaths().all { it.exists } + } + + val isClasspathable: Boolean + get() = layoutComponent is LayoutComponent.Classpathable + } + + private class LayoutComponents(val layoutComponents: List) : + Iterable { + companion object { + fun of(idePath: Path, productInfo: ProductInfo): LayoutComponents { + val resolvedLayoutComponents = productInfo.layout + .map { ResolvedLayoutComponent(idePath, it) } + return LayoutComponents(resolvedLayoutComponents) + } + } + + override fun iterator() = layoutComponents.iterator() + } +} diff --git a/intellij-plugin-structure/structure-intellij-classes/src/main/java/com/jetbrains/plugin/structure/intellij/classes/plugin/IdePluginClassesLocations.kt b/intellij-plugin-structure/structure-intellij-classes/src/main/java/com/jetbrains/plugin/structure/intellij/classes/plugin/IdePluginClassesLocations.kt index 2f6c14060e..68337e40d9 100644 --- a/intellij-plugin-structure/structure-intellij-classes/src/main/java/com/jetbrains/plugin/structure/intellij/classes/plugin/IdePluginClassesLocations.kt +++ b/intellij-plugin-structure/structure-intellij-classes/src/main/java/com/jetbrains/plugin/structure/intellij/classes/plugin/IdePluginClassesLocations.kt @@ -1,5 +1,5 @@ /* - * Copyright 2000-2020 JetBrains s.r.o. and other contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + * Copyright 2000-2024 JetBrains s.r.o. and other contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. */ package com.jetbrains.plugin.structure.intellij.classes.plugin @@ -11,7 +11,7 @@ import com.jetbrains.plugin.structure.intellij.plugin.IdePlugin import java.io.Closeable /** - * Holder of the class files of the [plugin] [idePlugin] + * Holder of the class files of the [idePlugin] [idePlugin] * that could reside in different [locations] [LocationKey]. */ data class IdePluginClassesLocations( @@ -35,4 +35,5 @@ data class IdePluginClassesLocations( fun getResolvers(key: LocationKey): List = locations[key].orEmpty() + val locationKeys = locations.keys } diff --git a/intellij-plugin-structure/structure-intellij/src/main/java/com/jetbrains/plugin/structure/intellij/platform/ProductInfo.kt b/intellij-plugin-structure/structure-intellij/src/main/java/com/jetbrains/plugin/structure/intellij/platform/ProductInfo.kt index 7a87d3bc5b..686270f821 100644 --- a/intellij-plugin-structure/structure-intellij/src/main/java/com/jetbrains/plugin/structure/intellij/platform/ProductInfo.kt +++ b/intellij-plugin-structure/structure-intellij/src/main/java/com/jetbrains/plugin/structure/intellij/platform/ProductInfo.kt @@ -18,10 +18,13 @@ data class ProductInfo( @JsonProperty("dataDirectoryName") val dataDirectoryName: String, @JsonProperty("svgIconPath") val svgIconPath: String, @JsonProperty("productVendor") val productVendor: String, + @JsonProperty("launch") val launch: List?, @JsonProperty("bundledPlugins") val bundledPlugins: List, @JsonProperty("modules") val modules: List, @JsonProperty("layout") val layout: List -) +) { + val launches: List = launch ?: emptyList() +} @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "kind") @JsonSubTypes( @@ -81,5 +84,9 @@ sealed class LayoutComponent(val kind: String) { get() = map { Path.of(it) } } +@JsonIgnoreProperties(ignoreUnknown = true) +data class Launch( + @JsonProperty("bootClassPathJarNames") val bootClassPathJarNames: List +) diff --git a/intellij-plugin-structure/structure-intellij/src/main/java/com/jetbrains/plugin/structure/intellij/plugin/PluginCreator.kt b/intellij-plugin-structure/structure-intellij/src/main/java/com/jetbrains/plugin/structure/intellij/plugin/PluginCreator.kt index 2d2ea1f50c..0ebba5e0ce 100644 --- a/intellij-plugin-structure/structure-intellij/src/main/java/com/jetbrains/plugin/structure/intellij/plugin/PluginCreator.kt +++ b/intellij-plugin-structure/structure-intellij/src/main/java/com/jetbrains/plugin/structure/intellij/plugin/PluginCreator.kt @@ -1,5 +1,5 @@ /* - * Copyright 2000-2020 JetBrains s.r.o. and other contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + * Copyright 2000-2024 JetBrains s.r.o. and other contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. */ package com.jetbrains.plugin.structure.intellij.plugin diff --git a/intellij-plugin-structure/structure-intellij/src/main/java/com/jetbrains/plugin/structure/intellij/plugin/dependencies/DefaultDependenciesProvider.kt b/intellij-plugin-structure/structure-intellij/src/main/java/com/jetbrains/plugin/structure/intellij/plugin/dependencies/DefaultDependenciesProvider.kt new file mode 100644 index 0000000000..7f8517c75c --- /dev/null +++ b/intellij-plugin-structure/structure-intellij/src/main/java/com/jetbrains/plugin/structure/intellij/plugin/dependencies/DefaultDependenciesProvider.kt @@ -0,0 +1,14 @@ +/* + * Copyright 2000-2024 JetBrains s.r.o. and other contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package com.jetbrains.plugin.structure.intellij.plugin.dependencies + +import com.jetbrains.plugin.structure.intellij.plugin.IdePlugin +import com.jetbrains.plugin.structure.intellij.plugin.PluginProvider + +class DefaultDependenciesProvider(pluginProvider: PluginProvider) : DependenciesProvider { + private val dependencyTree = DependencyTree(pluginProvider) + + override fun getDependencies(plugin: IdePlugin) = dependencyTree.getTransitiveDependencies(plugin) +} \ No newline at end of file diff --git a/intellij-plugin-structure/structure-intellij/src/main/java/com/jetbrains/plugin/structure/intellij/plugin/dependencies/DependenciesProvider.kt b/intellij-plugin-structure/structure-intellij/src/main/java/com/jetbrains/plugin/structure/intellij/plugin/dependencies/DependenciesProvider.kt new file mode 100644 index 0000000000..f9440de367 --- /dev/null +++ b/intellij-plugin-structure/structure-intellij/src/main/java/com/jetbrains/plugin/structure/intellij/plugin/dependencies/DependenciesProvider.kt @@ -0,0 +1,7 @@ +package com.jetbrains.plugin.structure.intellij.plugin.dependencies + +import com.jetbrains.plugin.structure.intellij.plugin.IdePlugin + +fun interface DependenciesProvider { + fun getDependencies(plugin: IdePlugin): Set +} \ No newline at end of file diff --git a/intellij-plugin-structure/structure-intellij/src/main/java/com/jetbrains/plugin/structure/intellij/plugin/dependencies/Dependency.kt b/intellij-plugin-structure/structure-intellij/src/main/java/com/jetbrains/plugin/structure/intellij/plugin/dependencies/Dependency.kt index f87c8d0843..686d408c01 100644 --- a/intellij-plugin-structure/structure-intellij/src/main/java/com/jetbrains/plugin/structure/intellij/plugin/dependencies/Dependency.kt +++ b/intellij-plugin-structure/structure-intellij/src/main/java/com/jetbrains/plugin/structure/intellij/plugin/dependencies/Dependency.kt @@ -16,7 +16,7 @@ sealed class Dependency { abstract val isTransitive: Boolean data class Module(override val plugin: IdePlugin, val id: PluginId, override val isTransitive: Boolean = false) : Dependency(), PluginAware { - override fun matches(id: PluginId) = plugin.pluginId == id + override fun matches(id: PluginId) = plugin.pluginId == id || plugin.definedModules.contains(id) override fun toString() = "${if (isTransitive) "Transitive " else ""}Module '$id' provided by plugin '${plugin.pluginId}'" diff --git a/intellij-plugin-structure/structure-intellij/src/main/java/com/jetbrains/plugin/structure/intellij/resources/Resolvers.kt b/intellij-plugin-structure/structure-intellij/src/main/java/com/jetbrains/plugin/structure/intellij/resources/Resolvers.kt new file mode 100644 index 0000000000..77df10d487 --- /dev/null +++ b/intellij-plugin-structure/structure-intellij/src/main/java/com/jetbrains/plugin/structure/intellij/resources/Resolvers.kt @@ -0,0 +1,5 @@ +package com.jetbrains.plugin.structure.intellij.resources + +fun List.asResolver(): ResourceResolver { + return CompositeResourceResolver(this) +} \ No newline at end of file diff --git a/intellij-plugin-structure/structure-intellij/src/test/kotlin/com/jetbrains/plugin/structure/intellij/platform/ProductInfoParserTest.kt b/intellij-plugin-structure/structure-intellij/src/test/kotlin/com/jetbrains/plugin/structure/intellij/platform/ProductInfoParserTest.kt index 8a1cc17f20..543cf51995 100644 --- a/intellij-plugin-structure/structure-intellij/src/test/kotlin/com/jetbrains/plugin/structure/intellij/platform/ProductInfoParserTest.kt +++ b/intellij-plugin-structure/structure-intellij/src/test/kotlin/com/jetbrains/plugin/structure/intellij/platform/ProductInfoParserTest.kt @@ -46,20 +46,21 @@ class ProductInfoParserTest { val jackson = ObjectMapper() val expectedJson = """ - {"name":"name","version":"version","versionSuffix":"versioNnuffix","buildNumber":"buildNumber","productCode":"productCode","dataDirectoryName":"dataDirectoryName","svgIconPath":"svgIconPath","productVendor":"productVendor","bundledPlugins":[],"modules":[],"layout":[{"name":"Coverage","kind":"plugin","classPaths":["plugins/java-coverage/lib/java-coverage.jar","plugins/java-coverage/lib/java-coverage-rt.jar"]},{"name":"com.intellij.modules.json","kind":"pluginAlias"}]} + {"name":"name","version":"version","versionSuffix":"versionSuffix","buildNumber":"buildNumber","productCode":"productCode","dataDirectoryName":"dataDirectoryName","svgIconPath":"svgIconPath","productVendor":"productVendor","launch":[],"bundledPlugins":[],"modules":[],"layout":[{"name":"Coverage","kind":"plugin","classPaths":["plugins/java-coverage/lib/java-coverage.jar","plugins/java-coverage/lib/java-coverage-rt.jar"]},{"name":"com.intellij.modules.json","kind":"pluginAlias"}],"launches":[]} """.trimIndent() val productInfo = ProductInfo( "name", "version", - "versioNnuffix", + "versionSuffix", "buildNumber", "productCode", "dataDirectoryName", "svgIconPath", "productVendor", - emptyList(), - emptyList(), - listOf( + launch = emptyList(), + bundledPlugins = emptyList(), + modules = emptyList(), + layout = listOf( LayoutComponent.Plugin("Coverage", listOf( "plugins/java-coverage/lib/java-coverage.jar", "plugins/java-coverage/lib/java-coverage-rt.jar", diff --git a/intellij-plugin-structure/tests/build.gradle.kts b/intellij-plugin-structure/tests/build.gradle.kts index 70cb2aa6a8..3262ce90c1 100644 --- a/intellij-plugin-structure/tests/build.gradle.kts +++ b/intellij-plugin-structure/tests/build.gradle.kts @@ -18,4 +18,6 @@ dependencies { testImplementation(libs.jackson.yaml) testImplementation(libs.semver4j) testRuntimeOnly(sharedLibs.logback.classic) + + testImplementation(sharedLibs.byteBuddy) } \ No newline at end of file diff --git a/intellij-plugin-structure/tests/src/test/kotlin/com/jetbrains/plugin/structure/base/utils/contentBuilder/ContentBuilderTest.kt b/intellij-plugin-structure/tests/src/test/kotlin/com/jetbrains/plugin/structure/base/utils/contentBuilder/ContentBuilderTest.kt new file mode 100644 index 0000000000..0312ae3fb8 --- /dev/null +++ b/intellij-plugin-structure/tests/src/test/kotlin/com/jetbrains/plugin/structure/base/utils/contentBuilder/ContentBuilderTest.kt @@ -0,0 +1,130 @@ +package com.jetbrains.plugin.structure.base.utils.contentBuilder + +import com.google.common.jimfs.Configuration +import com.google.common.jimfs.Jimfs +import com.jetbrains.plugin.structure.base.utils.exists +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.file.Path + +class ContentBuilderTest { + @Test + fun `single directory is properly constructed`() { + Jimfs.newFileSystem(Configuration.unix()).use { jimFs -> + val rootDir: Path = jimFs.rootDirectories.first() + + buildDirectory(rootDir) { + dirs("jetbrains") { + file("build.txt", "IU-243.12818.78") + } + } + val expectedPath = jimFs.getPath("/jetbrains/build.txt") + assertTrue(expectedPath.exists()) + } + } + + @Test + fun `single directory with empty content is properly constructed`() { + Jimfs.newFileSystem(Configuration.unix()).use { jimFs -> + val rootDir: Path = jimFs.rootDirectories.first() + + buildDirectory(rootDir) { + dirs("jetbrains") { /* empty directory */ } + } + val expectedPath = jimFs.getPath("/jetbrains") + assertTrue(expectedPath.exists()) + } + } + + @Test + fun `directory with a subdirectory that contains an empty content is properly constructed`() { + Jimfs.newFileSystem(Configuration.unix()).use { jimFs -> + val rootDir: Path = jimFs.rootDirectories.first() + + buildDirectory(rootDir) { + dirs("jetbrains/ide") { /* empty directory */ } + } + val expectedPath = jimFs.getPath("/jetbrains/ide") + assertTrue(expectedPath.exists()) + } + } + + @Test + fun `directories are properly constructed`() { + Jimfs.newFileSystem(Configuration.unix()).use { jimFs -> + val rootDir: Path = jimFs.rootDirectories.first() + + buildDirectory(rootDir) { + dirs("jetbrains/ide/idea") { + file("build.txt", "IU-243.12818.78") + } + } + val expectedPath = jimFs.getPath("/jetbrains/ide/idea/build.txt") + assertTrue(expectedPath.exists()) + } + } + + @Test + fun `directories are properly constructed with deeply nested directories`() { + Jimfs.newFileSystem(Configuration.unix()).use { jimFs -> + val rootDir: Path = jimFs.rootDirectories.first() + + buildDirectory(rootDir) { + dirs("jetbrains/products/ide/idea") { + file("build.txt", "IU-243.12818.78") + } + } + val expectedPath = jimFs.getPath("/jetbrains/products/ide/idea/build.txt") + assertTrue(expectedPath.exists()) + } + } + + @Test + fun `directories are properly constructed with 5 layers of nested directories`() { + Jimfs.newFileSystem(Configuration.unix()).use { jimFs -> + val rootDir: Path = jimFs.rootDirectories.first() + + buildDirectory(rootDir) { + dirs("dev/jetbrains/products/ide/idea") { + file("build.txt", "IU-243.12818.78") + } + } + val expectedPath = jimFs.getPath("/dev/jetbrains/products/ide/idea/build.txt") + assertTrue(expectedPath.exists()) + } + } + + @Test + fun `directories are properly constructed with single dir`() { + Jimfs.newFileSystem(Configuration.unix()).use { jimFs -> + val rootDir: Path = jimFs.rootDirectories.first() + + buildDirectory(rootDir) { + dirs("idea") { + dir("lib") { + file("idea_rt.jar") + } + } + } + val expectedPath = jimFs.getPath("/idea/lib/idea_rt.jar") + assertTrue(expectedPath.exists()) + } + } + + @Test + fun `directories are properly constructed with deeply nested directories and explicitly nested directory`() { + Jimfs.newFileSystem(Configuration.unix()).use { jimFs -> + val rootDir: Path = jimFs.rootDirectories.first() + + buildDirectory(rootDir) { + dirs("idea/lib") { + dir("rt") { + file("servlet.jar") + } + } + } + val expectedPath = jimFs.getPath("/idea/lib/rt/servlet.jar") + assertTrue(expectedPath.exists()) + } + } +} \ No newline at end of file diff --git a/intellij-plugin-structure/tests/src/test/kotlin/com/jetbrains/plugin/structure/ide/classes/resolver/PluginDependencyFilteredResolverTest.kt b/intellij-plugin-structure/tests/src/test/kotlin/com/jetbrains/plugin/structure/ide/classes/resolver/PluginDependencyFilteredResolverTest.kt new file mode 100644 index 0000000000..0ace9558f0 --- /dev/null +++ b/intellij-plugin-structure/tests/src/test/kotlin/com/jetbrains/plugin/structure/ide/classes/resolver/PluginDependencyFilteredResolverTest.kt @@ -0,0 +1,299 @@ +package com.jetbrains.plugin.structure.ide.classes.resolver + +import com.jetbrains.plugin.structure.base.utils.createParentDirs +import com.jetbrains.plugin.structure.classes.resolvers.ResolutionResult +import com.jetbrains.plugin.structure.intellij.platform.Launch +import com.jetbrains.plugin.structure.intellij.platform.LayoutComponent +import com.jetbrains.plugin.structure.intellij.platform.LayoutComponent.Plugin +import com.jetbrains.plugin.structure.intellij.platform.LayoutComponent.PluginAlias +import com.jetbrains.plugin.structure.intellij.platform.ProductInfo +import com.jetbrains.plugin.structure.intellij.plugin.IdePlugin +import com.jetbrains.plugin.structure.intellij.plugin.ModuleV2Dependency +import com.jetbrains.plugin.structure.intellij.plugin.PluginDependencyImpl +import com.jetbrains.plugin.structure.intellij.version.IdeVersion +import com.jetbrains.plugin.structure.mocks.MockIde +import com.jetbrains.plugin.structure.mocks.MockIdePlugin +import net.bytebuddy.ByteBuddy +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.FileOutputStream +import java.nio.file.Files +import java.nio.file.Path +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +class PluginDependencyFilteredResolverTest { + @Rule + @JvmField + val temporaryFolder = TemporaryFolder() + + private lateinit var ideRoot: Path + + private lateinit var ideaCorePlugin: IdePlugin + private lateinit var javaPlugin: IdePlugin + private lateinit var jsonPlugin: IdePlugin + + private lateinit var byteBuddy: ByteBuddy + + private fun zip(zipPath: Path, fullyQualifiedName: String) { + ZipOutputStream(FileOutputStream(zipPath.toFile())).use { + val zipEntryName = fullyQualifiedName.replace('.', '/') + ".class" + it.putNextEntry(ZipEntry(zipEntryName)) + it.write(emptyClass(fullyQualifiedName)) + it.closeEntry() + } + } + + private fun emptyClass(fullyQualifiedName: String): ByteArray { + return byteBuddy + .subclass(Object::class.java) + .name(fullyQualifiedName) + .make() + .bytes + } + + private fun IdePlugin.writeEmptyClass(fullyQualifiedName: String): IdePlugin { + originalFile?.let { pluginArtifact: Path -> + zip(pluginArtifact, fullyQualifiedName) + } + return this + } + + @Before + fun setUp() { + byteBuddy = ByteBuddy() + + ideRoot = temporaryFolder.newFolder("idea").toPath() + + ideaCorePlugin = MockIdePlugin( + pluginId = "com.intellij", + pluginName = "IDEA CORE", + originalFile = temporaryFolder.newTemporaryFile("idea/lib/product.jar"), + definedModules = setOf( + "com.intellij.modules.platform", + "com.intellij.modules.lang", + "com.intellij.modules.java", + ) + ).also { + it.writeEmptyClass("com.intellij.openapi.editor.Caret") + } + + javaPlugin = MockIdePlugin( + pluginId = "com.intellij.java", + pluginName = "Java", + originalFile = temporaryFolder.newTemporaryFile("idea/plugins/java/lib/java-impl.jar"), + definedModules = setOf( + "com.intellij.modules.java", + ), + dependencies = listOf( + ModuleV2Dependency("com.intellij.modules.lang") + ) + ) + + jsonPlugin = MockIdePlugin( + pluginId = "com.intellij.modules.json", + pluginName = "JSON", + originalFile = temporaryFolder.newTemporaryFile("idea/plugins/json/lib/json.jar") + ) + } + + @Test + fun `plugin dependency-based resolvers are resolved`() { + val ideVersion = IdeVersion.createIdeVersion("IU-243.12818.47") + + val plugin = MockIdePlugin( + pluginId = "com.example.somePlugin", + dependencies = listOf( + PluginDependencyImpl(/* id = */ "com.intellij.modules.platform", + /* isOptional = */ false, + /* isModule = */ true + ), + PluginDependencyImpl(/* id = */ "com.intellij.modules.json", + /* isOptional = */ false, + /* isModule = */ true + ), + ) + ) + + val productInfo = ProductInfo( + name = "IntelliJ IDEA", + version = "2024.3", + versionSuffix = "EAP", + buildNumber = ideVersion.asStringWithoutProductCode(), + productCode = "IU", + dataDirectoryName = "IntelliJIdea2024.3", + productVendor = "JetBrains", + launch = emptyList(), + svgIconPath = "bin/idea.svg", + modules = emptyList(), + bundledPlugins = emptyList(), + layout = listOf( + PluginAlias("com.intellij.modules.platform"), + Plugin("com.intellij.modules.json", listOf("plugins/json/lib/json.jar")), + Plugin("Git4Idea", listOf("plugins/vcs-git/lib/vcs-git.jar", "plugins/vcs-git/lib/git4idea-rt.jar")), + ), + ) + + productInfo.createEmptyLayoutComponentPaths(ideRoot) + + val ide = MockIde(ideVersion, ideRoot, bundledPlugins = listOf(ideaCorePlugin, jsonPlugin)) + + val productInfoClassResolver = ProductInfoClassResolver(productInfo, ide) + val pluginDependencyFilteredResolver = PluginDependencyFilteredResolver(plugin, productInfoClassResolver) + + with(pluginDependencyFilteredResolver.filteredResolvers) { + assertEquals(2, size) + + // pluginAlias has no classpath, hence no resolver + assertFalse(containsName("com.intellij.modules.platform")) + // JSON plugin is declared + assertTrue(containsName("com.intellij.modules.json")) + // Git4Idea is not a plugin dependency + assertFalse(containsName("Git4Idea")) + } + } + + @Test + fun `transitive plugin dependencies are filtered`() { + val ideVersion = IdeVersion.createIdeVersion("IU-243.12818.47") + + val plugin = MockIdePlugin( + pluginId = "com.example.somePlugin", + vendor = "JetBrains", + dependencies = listOf( + PluginDependencyImpl(/* id = */ "com.intellij.modules.lang", + /* isOptional = */ false, + /* isModule = */ true + ), + PluginDependencyImpl(/* id = */ "com.intellij.modules.json", + /* isOptional = */ false, + /* isModule = */ true + ), + ) + ) + + val productInfo = ProductInfo( + name = "IntelliJ IDEA", + version = "2024.3", + versionSuffix = "EAP", + buildNumber = ideVersion.asStringWithoutProductCode(), + productCode = "IU", + dataDirectoryName = "IntelliJIdea2024.3", + productVendor = "JetBrains", + svgIconPath = "bin/idea.svg", + modules = emptyList(), + bundledPlugins = emptyList(), + layout = listOf( + Plugin("com.intellij.modules.json", listOf("plugins/json/lib/json.jar")), + PluginAlias("com.intellij.modules.lang"), + ), + launch = listOf( + Launch(bootClassPathJarNames = listOf("product.jar")) + ) + ) + + productInfo.createEmptyLayoutComponentPaths(ideRoot) + + val bundledPlugins = listOf(ideaCorePlugin, jsonPlugin) + val ide = MockIde(ideVersion, ideRoot, bundledPlugins) + + val productInfoClassResolver = ProductInfoClassResolver(productInfo, ide) + val pluginDependencyFilteredResolver = PluginDependencyFilteredResolver(plugin, productInfoClassResolver) + + val editorCaretClassName = "com/intellij/openapi/editor/Caret" + val editorCaretClassResolution = pluginDependencyFilteredResolver.resolveClass(editorCaretClassName) + assertTrue( + "Class '$editorCaretClassName' must be 'Found', but is '${editorCaretClassResolution.javaClass}'", + editorCaretClassResolution is ResolutionResult.Found + ) + } + + @Test + fun `two-tier transitive plugin dependencies are filtered`() { + val ideVersion = IdeVersion.createIdeVersion("IU-243.12818.47") + + val plugin = MockIdePlugin( + pluginId = "com.example.somePlugin", + vendor = "JetBrains", + dependencies = listOf( + PluginDependencyImpl(/* id = */ "com.intellij.java", + /* isOptional = */ false, + /* isModule = */ false + ), + PluginDependencyImpl(/* id = */ "com.intellij.modules.json", + /* isOptional = */ false, + /* isModule = */ true + ), + ) + ) + + val productInfo = ProductInfo( + name = "IntelliJ IDEA", + version = "2024.3", + versionSuffix = "EAP", + buildNumber = ideVersion.asStringWithoutProductCode(), + productCode = "IU", + dataDirectoryName = "IntelliJIdea2024.3", + productVendor = "JetBrains", + svgIconPath = "bin/idea.svg", + modules = emptyList(), + bundledPlugins = emptyList(), + layout = listOf( + Plugin("com.intellij.modules.json", listOf("plugins/json/lib/json.jar")), + Plugin("com.intellij.java", listOf("plugins/java/lib/java-impl.jar")), + PluginAlias("com.intellij.modules.lang"), + ), + launch = listOf( + Launch(bootClassPathJarNames = listOf("product.jar")) + ) + ) + + productInfo.createEmptyLayoutComponentPaths(ideRoot) + + val bundledPlugins = listOf(ideaCorePlugin, jsonPlugin, javaPlugin) + val ide = MockIde(ideVersion, ideRoot, bundledPlugins) + + val productInfoClassResolver = ProductInfoClassResolver(productInfo, ide) + val pluginDependencyFilteredResolver = PluginDependencyFilteredResolver(plugin, productInfoClassResolver) + + val editorCaretClassName = "com/intellij/openapi/editor/Caret" + val editorCaretClassResolution = pluginDependencyFilteredResolver.resolveClass(editorCaretClassName) + assertTrue( + "Class '$editorCaretClassName' must be 'Found', but is '${editorCaretClassResolution.javaClass}'", + editorCaretClassResolution is ResolutionResult.Found + ) + } + + private fun List.containsName(name: String) = any { it.name == name } + + private fun ProductInfo.createEmptyLayoutComponentPaths(ideRoot: Path) { + layout + .flatMap { if (it is LayoutComponent.Classpathable) it.getClasspath() else emptyList() } + .map { ideRoot.resolve(it) } + .map { + it.apply { createParentDirs() } + } + .forEach { + it.createEmptyZip() + } + } + + private fun Path.createEmptyZip() { + ZipOutputStream(Files.newOutputStream(this)).use {} + } + + private fun TemporaryFolder.newTemporaryFile(filePath: String): Path { + val pathComponents = filePath.split("/") + val dirComponents = pathComponents.dropLast(1).toTypedArray() + if (dirComponents.isEmpty()) { + throw IllegalArgumentException("Cannot create temporary file '$filePath'") + } + val fileComponent = pathComponents.last() + val folder: File = newFolder(*dirComponents) + return File(folder, fileComponent).toPath() + } +} \ No newline at end of file diff --git a/intellij-plugin-structure/tests/src/test/kotlin/com/jetbrains/plugin/structure/ide/classes/resolver/ProductInfoClassResolverTest.kt b/intellij-plugin-structure/tests/src/test/kotlin/com/jetbrains/plugin/structure/ide/classes/resolver/ProductInfoClassResolverTest.kt new file mode 100644 index 0000000000..04c33317cd --- /dev/null +++ b/intellij-plugin-structure/tests/src/test/kotlin/com/jetbrains/plugin/structure/ide/classes/resolver/ProductInfoClassResolverTest.kt @@ -0,0 +1,165 @@ +package com.jetbrains.plugin.structure.ide.classes.resolver + +import com.jetbrains.plugin.structure.base.utils.createParentDirs +import com.jetbrains.plugin.structure.base.utils.writeText +import com.jetbrains.plugin.structure.classes.resolvers.CompositeResolver +import com.jetbrains.plugin.structure.intellij.version.IdeVersion +import com.jetbrains.plugin.structure.mocks.MockIde +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.net.URL +import java.nio.file.Files +import java.nio.file.Path +import java.util.zip.ZipOutputStream + +private const val IDEA_ULTIMATE_2024_2 = "IU-242.18071.24" + +class ProductInfoClassResolverTest { + @Rule + @JvmField + val temporaryFolder = TemporaryFolder() + + private lateinit var ideRoot: Path + + @Before + fun setUp() { + with(temporaryFolder.newFolder("idea")) { + ideRoot = toPath() + val productInfoJsonPath = ideRoot.resolve("product-info.json") + copyResource("/ide/productInfo/product-info_mini.json", productInfoJsonPath) + + ideRoot.resolve("build.txt").writeText(IDEA_ULTIMATE_2024_2) + + createEmptyIdeFiles() + } + } + + @Test + fun `resolver is created from an IDE instance`() { + val ide = MockIde(IdeVersion.createIdeVersion(IDEA_ULTIMATE_2024_2), ideRoot) + val resolver = ProductInfoClassResolver.of(ide) + with(resolver.layoutComponentResolvers.map { it.name }) { + assertEquals(5, size) + assertEquals( + listOf( + "Git4Idea", + "com.intellij", + "intellij.copyright.vcs", + "intellij.execution.process.elevation", + "intellij.java.featuresTrainer" + ), this + ) + } + with(resolver.bootClasspathResolver) { + assertNotNull(this) + assertTrue(delegateResolver is CompositeResolver) + } + } + + @Test + fun `resolver supports 242+ IDE`() { + assertTrue(ProductInfoClassResolver.supports(ideRoot)) + } + + private fun copyResource(resource: String, targetFile: Path) { + val url: URL = this::class.java.getResource(resource) ?: throw AssertionError("Resource '$resource' not found") + url.openStream().use { + Files.copy(it, targetFile) + } + } + + private fun createEmptyIdeFiles() { + ideFiles.flatMap { (_, files) -> files } + .map { file -> + ideRoot.resolve(file).apply { + createParentDirs() + } + }.forEach { + it.createEmptyZip() + } + } + + private fun Path.createEmptyZip() { + ZipOutputStream(Files.newOutputStream(this)).use {} + } + + + private val ideFiles = mapOf>( + // boot classpath, generally mapped to "bootClassPathJarNames" from product-info.json + "IDEA Core" to listOf( + "lib/platform-loader.jar", + "lib/util-8.jar", + "lib/util.jar", + "lib/app-client.jar", + "lib/util_rt.jar", + "lib/product.jar", + "lib/opentelemetry.jar", + "lib/app.jar", + "lib/product-client.jar", + "lib/lib-client.jar", + "lib/stats.jar", + "lib/jps-model.jar", + "lib/external-system-rt.jar", + "lib/rd.jar", + "lib/bouncy-castle.jar", + "lib/protobuf.jar", + "lib/intellij-test-discovery.jar", + "lib/forms_rt.jar", + "lib/lib.jar", + "lib/externalProcess-rt.jar", + "lib/groovy.jar", + "lib/annotations.jar", + "lib/idea_rt.jar", + "lib/jsch-agent.jar", + "lib/junit4.jar", + "lib/nio-fs.jar", + "lib/trove.jar" + ), + + "Git4Idea" to listOf( + "plugins/vcs-git/lib/vcs-git.jar", + "plugins/vcs-git/lib/git4idea-rt.jar" + ), + "com.intellij" to listOf( + "lib/platform-loader.jar", + "lib/util-8.jar", + "lib/util.jar", + "lib/util_rt.jar", + "lib/product.jar", + "lib/opentelemetry.jar", + "lib/app.jar", + "lib/stats.jar", + "lib/jps-model.jar", + "lib/external-system-rt.jar", + "lib/rd.jar", + "lib/bouncy-castle.jar", + "lib/protobuf.jar", + "lib/intellij-test-discovery.jar", + "lib/forms_rt.jar", + "lib/lib.jar", + "lib/externalProcess-rt.jar", + "lib/groovy.jar", + "lib/annotations.jar", + "lib/idea_rt.jar", + "lib/intellij-coverage-agent-1.0.750.jar", + "lib/jsch-agent.jar", + "lib/junit.jar", + "lib/junit4.jar", + "lib/nio-fs.jar", + "lib/testFramework.jar", + "lib/trove.jar" + ), + "intellij.execution.process.elevation" to listOf( + "lib/modules/intellij.execution.process.elevation.jar" + ), + "intellij.java.featuresTrainer" to listOf( + "plugins/java/lib/modules/intellij.java.featuresTrainer.jar" + ) + ) +} + +private typealias PluginId = String + diff --git a/intellij-plugin-structure/tests/src/test/kotlin/com/jetbrains/plugin/structure/mocks/MockIde.kt b/intellij-plugin-structure/tests/src/test/kotlin/com/jetbrains/plugin/structure/mocks/MockIde.kt new file mode 100644 index 0000000000..2d8d7bcc1f --- /dev/null +++ b/intellij-plugin-structure/tests/src/test/kotlin/com/jetbrains/plugin/structure/mocks/MockIde.kt @@ -0,0 +1,19 @@ +package com.jetbrains.plugin.structure.mocks + +import com.jetbrains.plugin.structure.ide.Ide +import com.jetbrains.plugin.structure.intellij.plugin.IdePlugin +import com.jetbrains.plugin.structure.intellij.version.IdeVersion +import java.nio.file.Path + +data class MockIde( + private val ideVersion: IdeVersion, + private val idePath: Path, + private val bundledPlugins: List = emptyList() +) : Ide() { + + override fun getIdePath() = idePath + + override fun getVersion() = ideVersion + + override fun getBundledPlugins() = bundledPlugins +} \ No newline at end of file diff --git a/intellij-plugin-structure/tests/src/test/kotlin/com/jetbrains/plugin/structure/mocks/MockIdePlugin.kt b/intellij-plugin-structure/tests/src/test/kotlin/com/jetbrains/plugin/structure/mocks/MockIdePlugin.kt new file mode 100644 index 0000000000..f44c092c83 --- /dev/null +++ b/intellij-plugin-structure/tests/src/test/kotlin/com/jetbrains/plugin/structure/mocks/MockIdePlugin.kt @@ -0,0 +1,57 @@ +package com.jetbrains.plugin.structure.mocks + +import com.jetbrains.plugin.structure.base.plugin.PluginIcon +import com.jetbrains.plugin.structure.base.plugin.ThirdPartyDependency +import com.jetbrains.plugin.structure.intellij.plugin.IdePlugin +import com.jetbrains.plugin.structure.intellij.plugin.IdePluginContentDescriptor +import com.jetbrains.plugin.structure.intellij.plugin.IdeTheme +import com.jetbrains.plugin.structure.intellij.plugin.KotlinPluginMode +import com.jetbrains.plugin.structure.intellij.plugin.ModuleDescriptor +import com.jetbrains.plugin.structure.intellij.plugin.MutableIdePluginContentDescriptor +import com.jetbrains.plugin.structure.intellij.plugin.OptionalPluginDescriptor +import com.jetbrains.plugin.structure.intellij.plugin.PluginDependency +import com.jetbrains.plugin.structure.intellij.plugin.ProductDescriptor +import com.jetbrains.plugin.structure.intellij.version.IdeVersion +import org.jdom2.Document +import org.jdom2.Element +import java.nio.file.Path + +data class MockIdePlugin( + override val pluginId: String? = null, + override val pluginName: String? = pluginId, + override val pluginVersion: String? = null, + override val description: String? = null, + override val url: String? = null, + override val vendor: String? = null, + override val vendorEmail: String? = null, + override val vendorUrl: String? = null, + override val changeNotes: String? = null, + override val icons: List = emptyList(), + override val productDescriptor: ProductDescriptor? = null, + override val dependencies: List = emptyList(), + override val incompatibleModules: List = emptyList(), + override val underlyingDocument: Document = Document(Element("idea-plugin")), + override val optionalDescriptors: List = emptyList(), + override val extensions: Map> = hashMapOf(), + override val sinceBuild: IdeVersion = IdeVersion.createIdeVersion("IU-163.1"), + override val untilBuild: IdeVersion? = null, + override val definedModules: Set = emptySet(), + override val originalFile: Path? = null, + override val appContainerDescriptor: IdePluginContentDescriptor = MutableIdePluginContentDescriptor(), + override val projectContainerDescriptor: IdePluginContentDescriptor = MutableIdePluginContentDescriptor(), + override val moduleContainerDescriptor: IdePluginContentDescriptor = MutableIdePluginContentDescriptor(), + override val thirdPartyDependencies: List = emptyList(), + override val modulesDescriptors: List = emptyList(), + override val isV2: Boolean = false, + override val kotlinPluginMode: KotlinPluginMode = KotlinPluginMode.Implicit +) : IdePlugin { + + override val useIdeClassLoader = false + override val isImplementationDetail = false + override val hasDotNetPart: Boolean = false + + override val declaredThemes = emptyList() + + override fun isCompatibleWithIde(ideVersion: IdeVersion) = + sinceBuild <= ideVersion && (untilBuild == null || ideVersion <= untilBuild) +} \ No newline at end of file diff --git a/intellij-plugin-structure/tests/src/test/resources/ide/productInfo/product-info_mini.json b/intellij-plugin-structure/tests/src/test/resources/ide/productInfo/product-info_mini.json new file mode 100644 index 0000000000..0bbbf60216 --- /dev/null +++ b/intellij-plugin-structure/tests/src/test/resources/ide/productInfo/product-info_mini.json @@ -0,0 +1,113 @@ +{ + "name": "IntelliJ IDEA", + "version": "2024.2", + "versionSuffix": "EAP", + "buildNumber": "242.10180.25", + "productCode": "IU", + "dataDirectoryName": "IntelliJIdea2024.2", + "svgIconPath": "bin/idea.svg", + "productVendor": "JetBrains", + "launch": [ + { + "os": "Linux", + "arch": "amd64", + "bootClassPathJarNames": [ + "platform-loader.jar", + "util-8.jar", + "util.jar", + "app-client.jar", + "util_rt.jar", + "product.jar", + "opentelemetry.jar", + "app.jar", + "product-client.jar", + "lib-client.jar", + "stats.jar", + "jps-model.jar", + "external-system-rt.jar", + "rd.jar", + "bouncy-castle.jar", + "protobuf.jar", + "intellij-test-discovery.jar", + "forms_rt.jar", + "lib.jar", + "externalProcess-rt.jar", + "groovy.jar", + "annotations.jar", + "idea_rt.jar", + "jsch-agent.jar", + "junit4.jar", + "nio-fs.jar", + "trove.jar" + ] + } + ], + "bundledPlugins": [], + "modules": [], + "fileExtensions": [], + "layout": [ + { + "name": "Git4Idea", + "kind": "plugin", + "classPath": [ + "plugins/vcs-git/lib/vcs-git.jar", + "plugins/vcs-git/lib/git4idea-rt.jar" + ] + }, + { + "name": "com.intellij", + "kind": "plugin", + "classPath": [ + "lib/platform-loader.jar", + "lib/util-8.jar", + "lib/util.jar", + "lib/util_rt.jar", + "lib/product.jar", + "lib/opentelemetry.jar", + "lib/app.jar", + "lib/stats.jar", + "lib/jps-model.jar", + "lib/external-system-rt.jar", + "lib/rd.jar", + "lib/bouncy-castle.jar", + "lib/protobuf.jar", + "lib/intellij-test-discovery.jar", + "lib/forms_rt.jar", + "lib/lib.jar", + "lib/externalProcess-rt.jar", + "lib/groovy.jar", + "lib/annotations.jar", + "lib/idea_rt.jar", + "lib/intellij-coverage-agent-1.0.750.jar", + "lib/jsch-agent.jar", + "lib/junit.jar", + "lib/junit4.jar", + "lib/nio-fs.jar", + "lib/testFramework.jar", + "lib/trove.jar" + ] + }, + { + "name": "com.intellij.modules.all", + "kind": "pluginAlias" + }, + { + "name": "intellij.copyright.vcs", + "kind": "moduleV2" + }, + { + "name": "intellij.execution.process.elevation", + "kind": "productModuleV2", + "classPath": [ + "lib/modules/intellij.execution.process.elevation.jar" + ] + }, + { + "name": "intellij.java.featuresTrainer", + "kind": "moduleV2", + "classPath": [ + "plugins/java/lib/modules/intellij.java.featuresTrainer.jar" + ] + } + ] +} \ No newline at end of file diff --git a/intellij-plugin-verifier/verifier-core/src/main/java/com/jetbrains/pluginverifier/results/problems/UndeclaredPluginDependencyProblem.kt b/intellij-plugin-verifier/verifier-core/src/main/java/com/jetbrains/pluginverifier/results/problems/UndeclaredPluginDependencyProblem.kt new file mode 100644 index 0000000000..c804de5580 --- /dev/null +++ b/intellij-plugin-verifier/verifier-core/src/main/java/com/jetbrains/pluginverifier/results/problems/UndeclaredPluginDependencyProblem.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2000-2024 JetBrains s.r.o. and other contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package com.jetbrains.pluginverifier.results.problems + +data class UndeclaredPluginDependencyProblem( + val undeclaredPluginId: String, + val classOrPackage: ApiElement, + val reason: String? +) : CompatibilityProblem() { + override val problemType: String = "Undeclared plugin dependency" + override val shortDescription: String = + "Plugin '$undeclaredPluginId' not declared as a plugin dependency for $classOrPackage" + reason?.let { ". $it" } + .orEmpty() + override val fullDescription: String = + "Plugin '$undeclaredPluginId' is not declared in the plugin descriptor as a dependency for $classOrPackage" + reason?.let { ". $it" } + .orEmpty() + + sealed class ApiElement { + data class Class(val className: String) : ApiElement() { + override fun toString(): String = "class $className" + } + + data class Package(val packageName: String) : ApiElement() { + override fun toString(): String = "package $packageName" + } + } +} \ No newline at end of file diff --git a/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/PluginVerifier.kt b/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/PluginVerifier.kt index 07c2288d88..f33fe9dd40 100644 --- a/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/PluginVerifier.kt +++ b/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/PluginVerifier.kt @@ -1,5 +1,5 @@ /* - * Copyright 2000-2023 JetBrains s.r.o. and other contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + * Copyright 2000-2024 JetBrains s.r.o. and other contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. */ package com.jetbrains.pluginverifier @@ -12,6 +12,7 @@ import com.jetbrains.plugin.structure.classes.resolvers.Resolver import com.jetbrains.plugin.structure.ide.util.KnownIdePackages import com.jetbrains.plugin.structure.intellij.classes.plugin.IdePluginClassesLocations import com.jetbrains.plugin.structure.intellij.plugin.IdePlugin +import com.jetbrains.pluginverifier.analysis.ExtractedJsonPluginAnalyzer import com.jetbrains.pluginverifier.analysis.ReachabilityGraph import com.jetbrains.pluginverifier.analysis.buildClassReachabilityGraph import com.jetbrains.pluginverifier.dependencies.DependenciesGraph @@ -56,6 +57,8 @@ class PluginVerifier( private val structureProblemsResolver = KotlinCompatibilityModeProblemResolver() + private val extractedJsonPluginAnalyzer = ExtractedJsonPluginAnalyzer() + fun loadPluginAndVerify(): PluginVerificationResult { pluginDetailsCache.getPluginDetailsCacheEntry(verificationDescriptor.checkedPlugin).use { cacheEntry -> return when (cacheEntry) { @@ -121,6 +124,8 @@ class PluginVerifier( ) ).verify(classesToCheck, context) {} + context.runAnalyzers() + analyzeMissingClassesCausedByMissingOptionalDependencies( context.compatibilityProblems, dependenciesGraph, @@ -233,6 +238,18 @@ class PluginVerifier( return null } + private fun PluginVerificationContext.runAnalyzers() { + if (verificationDescriptor is PluginVerificationDescriptor.IDE) { + val analyzedProblems = extractedJsonPluginAnalyzer.analyze( + verificationDescriptor.ide, + idePlugin, + compatibilityProblems + ) + compatibilityProblems += analyzedProblems.addedProblems + compatibilityProblems -= analyzedProblems.removedProblems + } + } + private fun analyzeMissingClassesCausedByMissingOptionalDependencies( compatibilityProblems: MutableSet, dependenciesGraph: DependenciesGraph, diff --git a/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/analysis/CompatibilityProblemChangeList.kt b/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/analysis/CompatibilityProblemChangeList.kt new file mode 100644 index 0000000000..3c50229f7c --- /dev/null +++ b/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/analysis/CompatibilityProblemChangeList.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2000-2024 JetBrains s.r.o. and other contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package com.jetbrains.pluginverifier.analysis + +import com.jetbrains.pluginverifier.results.problems.CompatibilityProblem + +class CompatibilityProblemChangeList { + + private val _addedProblems = hashSetOf() + + val addedProblems: Set + get() = _addedProblems + + private val _removedProblems = hashSetOf() + + val removedProblems: Set + get() = _removedProblems + + operator fun plusAssign(problem: CompatibilityProblem) { + _addedProblems += problem + } + + operator fun minusAssign(problem: CompatibilityProblem) { + _removedProblems += problem + } + + fun first(): CompatibilityProblem = problems.first() + + val problems: Set + get() = addedProblems - removedProblems + + val size: Int + get() = problems.size +} \ No newline at end of file diff --git a/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/analysis/ExtractedJsonPluginAnalyzer.kt b/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/analysis/ExtractedJsonPluginAnalyzer.kt new file mode 100644 index 0000000000..65b99c39a5 --- /dev/null +++ b/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/analysis/ExtractedJsonPluginAnalyzer.kt @@ -0,0 +1,133 @@ +/* + * Copyright 2000-2024 JetBrains s.r.o. and other contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package com.jetbrains.pluginverifier.analysis + +import com.jetbrains.plugin.structure.ide.Ide +import com.jetbrains.plugin.structure.intellij.plugin.IdePlugin +import com.jetbrains.plugin.structure.intellij.version.IdeVersion +import com.jetbrains.pluginverifier.results.problems.ClassNotFoundProblem +import com.jetbrains.pluginverifier.results.problems.CompatibilityProblem +import com.jetbrains.pluginverifier.results.problems.UndeclaredPluginDependencyProblem +import com.jetbrains.pluginverifier.results.problems.UndeclaredPluginDependencyProblem.ApiElement.Class +import com.jetbrains.pluginverifier.verifiers.resolution.BinaryClassName +import com.jetbrains.pluginverifier.verifiers.resolution.toFullyQualifiedClassName + +private const val JSON_PLUGIN_ID = "com.intellij.modules.json" + +private const val JSON_PLUGIN_EXTRACTED_REASON = "JSON support has been extracted to a separate plugin." + +class ExtractedJsonPluginAnalyzer { + private val removedPackages = listOf( + "com.intellij.json", + "com.intellij.json.codeinsight", + "com.intellij.json.highlighting", + "com.intellij.json.psi", + "com.jetbrains.jsonSchema" + ) + + private val removedClasses = listOf( + "com.intellij.json.JsonElementTypes", + "com.intellij.json.JsonFileType", + "com.intellij.json.JsonLanguage", + "com.intellij.json.JsonParserDefinition", + "com.intellij.json.JsonTokenType" + ) + + fun analyze( + ide: Ide, + plugin: IdePlugin, + compatibilityProblems: Collection + ): CompatibilityProblemChangeList { + return if (!supports(ide)) { + CompatibilityProblemChangeList() + } else { + CompatibilityProblemChangeList().also { problems -> + compatibilityProblems.filterIsInstance() + .forEach { problem -> + val className: BinaryClassName = problem.unresolved.className + if (isRemovedClass(className) || isRemovedPackage(className)) { + problems += undeclaredPluginDependency(className) + problems -= problem + } + } + } + } + } + + private fun supports(ide: Ide): Boolean = + isAtLeastVersion(ide, "243") + + private fun isRemovedClass(className: BinaryClassName): Boolean = + removedClasses.contains(className.toFullyQualifiedClassName()) + + private fun isRemovedPackage(className: BinaryClassName): Boolean { + val pkg = Package.of(className) + val removedPkg = getRemovedPackage(pkg) + return removedPkg != null + } + + private fun getRemovedPackage(pkg: Package): Package? { + val removedPackage = removedPackages.firstOrNull { it == pkg.name } + return if (removedPackage != null) { + Package(removedPackage) + } else { + pkg.parent.let { + if (it == Package.ROOT) { + null + } else { + getRemovedPackage(it) + } + } + } + } + + private fun undeclaredPluginDependency(className: BinaryClassName): UndeclaredPluginDependencyProblem { + return UndeclaredPluginDependencyProblem( + JSON_PLUGIN_ID, + Class(className.toFullyQualifiedClassName()), + JSON_PLUGIN_EXTRACTED_REASON + ) + } + + private fun isAtLeastVersion(ide: Ide, expectedVersion: String): Boolean { + return ide.version > IdeVersion.createIdeVersion(expectedVersion) + } + + internal class Package(val name: String) { + companion object { + val ROOT = Package("") + + fun of(className: BinaryClassName): Package { + return Package(className.packageName.toFullyQualifiedClassName()) + } + } + + private val elements: List = name.split(".") + + val parent: Package + get() { + val parentElements = elements.dropLast(1) + return if (parentElements.isEmpty()) { + ROOT + } else { + Package(parentElements.joinToString(".")) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Package + + return name == other.name + } + + override fun hashCode(): Int = name.hashCode() + } +} + +private val BinaryClassName.packageName get() = substringBeforeLast('/', "") + diff --git a/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/analysis/LegacyPluginAnalysis.kt b/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/analysis/LegacyPluginAnalysis.kt new file mode 100644 index 0000000000..d176be70e8 --- /dev/null +++ b/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/analysis/LegacyPluginAnalysis.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2000-2020 JetBrains s.r.o. and other contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package com.jetbrains.pluginverifier.analysis + +import com.jetbrains.plugin.structure.intellij.plugin.IdePlugin + +class LegacyPluginAnalysis { + // https://plugins.jetbrains.com/docs/intellij/plugin-compatibility.html#declaring-plugin-dependencies + fun isLegacyPlugin(plugin: IdePlugin): Boolean = with(plugin) { + !isV2 && (hasNoDependencies() || hasNoModuleDependencies()) + } + + private fun IdePlugin.hasNoDependencies() = dependencies.isEmpty() + + private fun IdePlugin.hasNoModuleDependencies() = dependencies.none { it.isModule } +} \ No newline at end of file diff --git a/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/dependencies/resolution/BundledPluginDependencyFinder.kt b/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/dependencies/resolution/BundledPluginDependencyFinder.kt index fda4efc80e..45bf8b7744 100644 --- a/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/dependencies/resolution/BundledPluginDependencyFinder.kt +++ b/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/dependencies/resolution/BundledPluginDependencyFinder.kt @@ -1,11 +1,12 @@ /* - * Copyright 2000-2020 JetBrains s.r.o. and other contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + * Copyright 2000-2024 JetBrains s.r.o. and other contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. */ package com.jetbrains.pluginverifier.dependencies.resolution import com.jetbrains.plugin.structure.ide.Ide import com.jetbrains.pluginverifier.plugin.PluginDetailsCache +import com.jetbrains.pluginverifier.repository.repositories.bundled.BundledPluginInfo import com.jetbrains.pluginverifier.repository.repositories.bundled.BundledPluginsRepository /** @@ -19,7 +20,7 @@ class BundledPluginDependencyFinder(val ide: Ide, private val pluginDetailsCache override fun findPluginDependency(dependencyId: String, isModule: Boolean): DependencyFinder.Result { val bundledPluginInfo = if (isModule) { - bundledPluginsRepository.findPluginByModule(dependencyId) + bundledPluginsRepository.findPluginOrModuleById(dependencyId) } else { bundledPluginsRepository.findPluginById(dependencyId) } @@ -30,4 +31,11 @@ class BundledPluginDependencyFinder(val ide: Ide, private val pluginDetailsCache return DependencyFinder.Result.NotFound("Dependency $dependencyId is not found among the bundled plugins of $ide") } + private fun BundledPluginsRepository.findPluginOrModuleById(dependencyId: String): BundledPluginInfo? { + // module can expose itself as a plugin with the corresponding ID + return bundledPluginsRepository.findPluginById(dependencyId) + // if there is no such plugin, search in modules + ?: bundledPluginsRepository.findPluginByModule(dependencyId) + } + } \ No newline at end of file diff --git a/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/resolution/BundledPluginClassResolverProvider.kt b/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/resolution/BundledPluginClassResolverProvider.kt index ebe7fc8868..c680c7c872 100644 --- a/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/resolution/BundledPluginClassResolverProvider.kt +++ b/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/resolution/BundledPluginClassResolverProvider.kt @@ -1,3 +1,7 @@ +/* + * Copyright 2000-2024 JetBrains s.r.o. and other contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + package com.jetbrains.pluginverifier.resolution import com.jetbrains.plugin.structure.classes.resolvers.CompositeResolver @@ -7,11 +11,11 @@ import com.jetbrains.pluginverifier.filtering.MainClassesSelector import com.jetbrains.pluginverifier.plugin.PluginDetails class BundledPluginClassResolverProvider { - private val bundledClassesSelectors = listOf(MainClassesSelector.forBundledPlugin(), ExternalBuildClassesSelector()) + private val bundledClassesSelectors = listOf(MainClassesSelector.forBundledPlugin(), ExternalBuildClassesSelector()) - fun getResolver(pluginDetails: PluginDetails): Resolver { - val classLocations = pluginDetails.pluginClassesLocations - return bundledClassesSelectors.flatMap { it.getClassLoader(classLocations) } - .let { CompositeResolver.create(it) } - } - } \ No newline at end of file + fun getResolver(pluginDetails: PluginDetails): Resolver { + val classLocations = pluginDetails.pluginClassesLocations + return bundledClassesSelectors.flatMap { it.getClassLoader(classLocations) } + .let { CompositeResolver.create(it, pluginDetails.pluginInfo.pluginId) } + } +} \ No newline at end of file diff --git a/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/resolution/DefaultClassResolverProvider.kt b/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/resolution/DefaultClassResolverProvider.kt index 40320aaf9d..509074ea2f 100644 --- a/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/resolution/DefaultClassResolverProvider.kt +++ b/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/resolution/DefaultClassResolverProvider.kt @@ -1,5 +1,5 @@ /* - * Copyright 2000-2020 JetBrains s.r.o. and other contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + * Copyright 2000-2024 JetBrains s.r.o. and other contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. */ package com.jetbrains.pluginverifier.resolution @@ -8,6 +8,11 @@ import com.jetbrains.plugin.structure.base.utils.closeOnException import com.jetbrains.plugin.structure.base.utils.rethrowIfInterrupted import com.jetbrains.plugin.structure.classes.resolvers.CompositeResolver import com.jetbrains.plugin.structure.classes.resolvers.Resolver +import com.jetbrains.plugin.structure.ide.ProductInfoBasedIde +import com.jetbrains.plugin.structure.ide.classes.resolver.PluginDependencyFilteredResolver +import com.jetbrains.plugin.structure.ide.classes.resolver.ProductInfoClassResolver +import com.jetbrains.plugin.structure.intellij.plugin.IdePlugin +import com.jetbrains.pluginverifier.analysis.LegacyPluginAnalysis import com.jetbrains.pluginverifier.createPluginResolver import com.jetbrains.pluginverifier.dependencies.DependenciesGraphBuilder import com.jetbrains.pluginverifier.dependencies.resolution.DependencyFinder @@ -23,11 +28,14 @@ class DefaultClassResolverProvider( private val dependencyFinder: DependencyFinder, private val ideDescriptor: IdeDescriptor, private val externalClassesPackageFilter: PackageFilter, - private val additionalClassResolvers: List = emptyList() + private val additionalClassResolvers: List = emptyList(), + private val pluginDetailsBasedResolverProvider: PluginDetailsBasedResolverProvider = DefaultPluginDetailsBasedResolverProvider() ) : ClassResolverProvider { private val bundledPluginClassResolverProvider = BundledPluginClassResolverProvider() + private val legacyPluginAnalysis = LegacyPluginAnalysis() + override fun provide(checkedPluginDetails: PluginDetails): ClassResolverProvider.Result { val closeableResources = arrayListOf() closeableResources.closeOnException { @@ -43,7 +51,7 @@ class DefaultClassResolverProvider( val resolvers = listOf( pluginResolver, ideDescriptor.jdkDescriptor.jdkResolver, - ideDescriptor.ideResolver, + getIdeResolver(checkedPluginDetails.idePlugin, ideDescriptor), dependenciesClassResolver ) + additionalClassResolvers @@ -54,12 +62,32 @@ class DefaultClassResolverProvider( override fun provideExternalClassesPackageFilter() = externalClassesPackageFilter + private fun getIdeResolver(plugin: IdePlugin, ideDescriptor: IdeDescriptor): Resolver { + return if (ideDescriptor.ide is ProductInfoBasedIde + && ideDescriptor.ideResolver is ProductInfoClassResolver + && !legacyPluginAnalysis.isLegacyPlugin(plugin) + ) { + PluginDependencyFilteredResolver(plugin, ideDescriptor.ideResolver) + } else { + ideDescriptor.ideResolver + } + } + private fun createPluginResolver(pluginDependency: PluginDetails): Resolver = when (pluginDependency.pluginInfo) { - is BundledPluginInfo -> bundledPluginClassResolverProvider.getResolver(pluginDependency) - else -> pluginDependency.pluginClassesLocations.createPluginResolver() + is BundledPluginInfo -> createBundledPluginResolver(pluginDependency) + ?: bundledPluginClassResolverProvider.getResolver(pluginDependency) + + else -> pluginDetailsBasedResolverProvider.getPluginResolver(pluginDependency) } + private fun createBundledPluginResolver(pluginDependency: PluginDetails): Resolver? { + return if (ideDescriptor.ide is ProductInfoBasedIde && ideDescriptor.ideResolver is ProductInfoClassResolver) { + ideDescriptor.ideResolver.layoutComponentResolvers + .firstOrNull { resolver -> resolver.name == pluginDependency.pluginInfo.pluginId } + } else null + } + private fun createDependenciesClassResolver(checkedPluginDetails: PluginDetails, dependencies: List): Resolver { val resolvers = mutableListOf() resolvers.closeOnException { @@ -71,7 +99,8 @@ class DefaultClassResolverProvider( resolvers += pluginDetails.mapNotNullInterruptible { createPluginResolver(it) } } - return CompositeResolver.create(resolvers) + val pluginId = checkedPluginDetails.pluginInfo.pluginId + return CompositeResolver.create(resolvers, resolverName = "Plugin Dependency Composite Resolver for '$pluginId'") } private inline fun Iterable.mapNotNullInterruptible(transform: (T) -> R): List { diff --git a/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/resolution/DefaultPluginDetailsBasedResolverProvider.kt b/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/resolution/DefaultPluginDetailsBasedResolverProvider.kt new file mode 100644 index 0000000000..d181beb341 --- /dev/null +++ b/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/resolution/DefaultPluginDetailsBasedResolverProvider.kt @@ -0,0 +1,13 @@ +/* + * Copyright 2000-2024 JetBrains s.r.o. and other contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package com.jetbrains.pluginverifier.resolution + +import com.jetbrains.pluginverifier.createPluginResolver +import com.jetbrains.pluginverifier.plugin.PluginDetails + +class DefaultPluginDetailsBasedResolverProvider : PluginDetailsBasedResolverProvider { + override fun getPluginResolver(pluginDependency: PluginDetails) = + pluginDependency.pluginClassesLocations.createPluginResolver() +} \ No newline at end of file diff --git a/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/resolution/PluginDetailsBasedResolverProvider.kt b/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/resolution/PluginDetailsBasedResolverProvider.kt new file mode 100644 index 0000000000..c77fc866f9 --- /dev/null +++ b/intellij-plugin-verifier/verifier-intellij/src/main/java/com/jetbrains/pluginverifier/resolution/PluginDetailsBasedResolverProvider.kt @@ -0,0 +1,12 @@ +/* + * Copyright 2000-2024 JetBrains s.r.o. and other contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package com.jetbrains.pluginverifier.resolution + +import com.jetbrains.plugin.structure.classes.resolvers.Resolver +import com.jetbrains.pluginverifier.plugin.PluginDetails + +fun interface PluginDetailsBasedResolverProvider { + fun getPluginResolver(pluginDependency: PluginDetails): Resolver +} \ No newline at end of file diff --git a/intellij-plugin-verifier/verifier-repository/src/main/java/com/jetbrains/pluginverifier/repository/repositories/marketplace/MarketplaceRepository.kt b/intellij-plugin-verifier/verifier-repository/src/main/java/com/jetbrains/pluginverifier/repository/repositories/marketplace/MarketplaceRepository.kt index 080f0461ca..c0c706fee0 100644 --- a/intellij-plugin-verifier/verifier-repository/src/main/java/com/jetbrains/pluginverifier/repository/repositories/marketplace/MarketplaceRepository.kt +++ b/intellij-plugin-verifier/verifier-repository/src/main/java/com/jetbrains/pluginverifier/repository/repositories/marketplace/MarketplaceRepository.kt @@ -8,6 +8,7 @@ import com.github.benmanes.caffeine.cache.Caffeine import com.github.benmanes.caffeine.cache.LoadingCache import com.jetbrains.plugin.structure.intellij.version.IdeVersion import com.jetbrains.pluginverifier.repository.PluginRepository +import com.jetbrains.pluginverifier.repository.repositories.tracing.withLogging import org.jetbrains.intellij.pluginRepository.PluginRepositoryFactory import org.jetbrains.intellij.pluginRepository.model.IntellijUpdateMetadata import org.jetbrains.intellij.pluginRepository.model.PluginId @@ -20,7 +21,8 @@ import java.util.concurrent.TimeUnit class MarketplaceRepository(val repositoryURL: URL = DEFAULT_URL) : PluginRepository { - private val pluginRepositoryInstance = PluginRepositoryFactory.create(host = repositoryURL.toExternalForm()) + private val pluginRepositoryInstance = + PluginRepositoryFactory.create(host = repositoryURL.toExternalForm()).withLogging() //This mapping never changes. Updates in JetBrains Marketplace have constant plugin ID. private val updateIdToPluginIdMapping = ConcurrentHashMap() diff --git a/intellij-plugin-verifier/verifier-repository/src/main/java/com/jetbrains/pluginverifier/repository/repositories/tracing/LoggingAndTracingPluginRepository.kt b/intellij-plugin-verifier/verifier-repository/src/main/java/com/jetbrains/pluginverifier/repository/repositories/tracing/LoggingAndTracingPluginRepository.kt new file mode 100644 index 0000000000..5c10409db4 --- /dev/null +++ b/intellij-plugin-verifier/verifier-repository/src/main/java/com/jetbrains/pluginverifier/repository/repositories/tracing/LoggingAndTracingPluginRepository.kt @@ -0,0 +1,259 @@ +package com.jetbrains.pluginverifier.repository.repositories.tracing + +import org.jetbrains.intellij.pluginRepository.PluginDownloader +import org.jetbrains.intellij.pluginRepository.PluginManager +import org.jetbrains.intellij.pluginRepository.PluginRepository +import org.jetbrains.intellij.pluginRepository.PluginUpdateManager +import org.jetbrains.intellij.pluginRepository.model.IntellijUpdateMetadata +import org.jetbrains.intellij.pluginRepository.model.PluginBean +import org.jetbrains.intellij.pluginRepository.model.PluginId +import org.jetbrains.intellij.pluginRepository.model.PluginUpdateBean +import org.jetbrains.intellij.pluginRepository.model.PluginUserBean +import org.jetbrains.intellij.pluginRepository.model.PluginXmlBean +import org.jetbrains.intellij.pluginRepository.model.ProductEnum +import org.jetbrains.intellij.pluginRepository.model.ProductFamily +import org.jetbrains.intellij.pluginRepository.model.StringPluginId +import org.jetbrains.intellij.pluginRepository.model.UpdateBean +import org.jetbrains.intellij.pluginRepository.model.UpdateDeleteBean +import org.jetbrains.intellij.pluginRepository.model.UpdateId +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.File + +private val LOG: Logger = LoggerFactory.getLogger(LoggingAndTracingPluginRepository::class.java) + +class LoggingAndTracingPluginRepository(private val delegateRepository: PluginRepository) : PluginRepository { + override val downloader: PluginDownloader get() = LoggingAndTracingPluginDownloader(delegateRepository.downloader) + override val pluginManager: PluginManager get() = LoggingAndTracingPluginManager(delegateRepository.pluginManager) + override val pluginUpdateManager: PluginUpdateManager get() = LoggingAndTracingPluginUpdateManager(delegateRepository.pluginUpdateManager) + override val uploader get() = delegateRepository.uploader + override val vendorManager get() = delegateRepository.vendorManager + + private class LoggingAndTracingPluginManager(private val delegate: PluginManager): PluginManager by delegate { + override fun getAllPluginsIds(): List { + LOG.debug("Retrieving all plugins identifiers") + return delegate.getAllPluginsIds() + } + + @Deprecated("Since IDEA 2020.2 is deprecated") + override fun getCompatiblePluginsXmlIds( + build: String, + max: Int, + offset: Int, + query: String + ): List { + LOG.debug("Getting compatible plugins identifiers for build '$build'. Max: $max, offset: $offset, query: $query") + return delegate.getCompatiblePluginsXmlIds(build, max, offset, query) + } + + override fun getPlugin(id: PluginId): PluginBean? { + LOG.debug("Getting plugin with ID=$id") + return delegate.getPlugin(id) + } + + override fun getPluginByXmlId( + xmlId: StringPluginId, + family: ProductFamily + ): PluginBean? { + LOG.debug("Retrieving plugin metadata for '$xmlId'", family.name.lowercase()) + return delegate.getPluginByXmlId( + xmlId, + family + ) + } + + override fun getPluginChannels(id: PluginId): List { + LOG.debug("Retrieving plugin channels for plugin '$id'") + return delegate.getPluginChannels(id) + } + + override fun getPluginCompatibleProducts(id: PluginId): List { + LOG.debug("Retrieving compatible products for plugin '$id'") + return delegate.getPluginCompatibleProducts(id) + } + + override fun getPluginDevelopers(id: PluginId): List { + LOG.debug("Retrieving plugin developers for plugin '$id'") + return delegate.getPluginDevelopers(id) + } + + override fun getPluginLastCompatibleUpdates( + build: String, + xmlId: StringPluginId + ): List { + LOG.debug("Getting last compatible updates for plugin '$xmlId' and build '$build'") + return delegate.getPluginLastCompatibleUpdates(build, xmlId) + } + + override fun getPluginVersions(id: PluginId): List { + LOG.debug("Retrieving plugin versions for plugin '$id'") + return delegate.getPluginVersions(id).also { + LOG.debug("Plugin '$id' has ${it.size} versions") + } + } + + override fun getPluginXmlIdByDependency( + dependency: String, + includeOptional: Boolean + ): List { + val optionalMsg = if (includeOptional) "optional " else "" + LOG.debug("Getting plugin XML IDs by ${optionalMsg}dependency '$dependency'") + return delegate.getPluginXmlIdByDependency( + dependency, + includeOptional + ).also { + LOG.debug("Found ${it.size} plugins for ${optionalMsg}dependency '$dependency'") + } + } + + @Deprecated("Will be removed for performance reasons") + override fun listPlugins( + ideBuild: String, + channel: String?, + pluginId: StringPluginId? + ): List { + LOG.debug("Listing plugins for IDE '$ideBuild' (channel '$channel')") + return delegate.listPlugins(ideBuild, channel, pluginId).also { + LOG.debug("Found ${it.size} plugins for IDE '$ideBuild' (channel '$channel')") + } + } + + override fun searchCompatibleUpdates( + xmlIds: List, + build: String, + channel: String, + module: String + ): List { + val channelMsg = if (channel.isNotBlank()) ", channel '$channel'" else "" + val specMsg = if (xmlIds.isEmpty() && module.isNotBlank()) { + "module $module$channelMsg" + } else { + val moduleMsg = if (module.isNotBlank()) ", module '$module'" else "" + val pluginIdMsg = when (xmlIds.size) { + 0 -> "no plugin IDs" + 1 -> xmlIds.first() + else -> xmlIds.joinToString() + } + "$pluginIdMsg for $build $channelMsg$moduleMsg" + } + + LOG.debug("Searching for compatible plugin updates of $specMsg") + return delegate.searchCompatibleUpdates( + xmlIds, + build, + channel, + module + ).also { + LOG.debug("Found ${it.size} compatible plugin updates of $specMsg") + } + } + } + + private class LoggingAndTracingPluginDownloader(private val delegate: PluginDownloader) : PluginDownloader { + override fun download( + xmlId: StringPluginId, + version: String, + targetPath: File, + channel: String? + ): File? { + LOG.debug("Downloading $xmlId:$version to [$targetPath] (channel: $channel)") + return delegate.download(xmlId, version, targetPath, channel) + } + + override fun download( + id: UpdateId, + targetPath: File + ): File? { + LOG.debug("Downloading update (ID=$id) to [$targetPath]") + return delegate.download(id, targetPath) + } + + override fun downloadLatestCompatiblePlugin( + xmlId: StringPluginId, + ideBuild: String, + targetPath: File, + channel: String? + ): File? { + LOG.debug("Downloading latest compatible plugin '$xmlId' with IDE '$ideBuild 'to [$targetPath] (channel: $channel)") + return delegate.downloadLatestCompatiblePlugin(xmlId, ideBuild, targetPath, channel) + } + + override fun downloadLatestCompatiblePluginViaBlockMap( + xmlId: StringPluginId, + ideBuild: String, + targetPath: File, + oldFile: File, + channel: String? + ): File? { + LOG.debug("Downloading latest compatible plugin '$xmlId' with IDE '$ideBuild 'to [$targetPath] (channel: $channel)") + return delegate.downloadLatestCompatiblePluginViaBlockMap(xmlId, ideBuild, targetPath, oldFile, channel) + } + + override fun downloadViaBlockMap( + xmlId: StringPluginId, + version: String, + targetPath: File, + oldFile: File, + channel: String? + ): File? { + LOG.debug("Downloading $xmlId:$version to [$targetPath] (channel: $channel, blockmap download via '$targetPath)'") + return delegate.downloadViaBlockMap(xmlId, version, targetPath, oldFile, channel) + } + + override fun downloadViaBlockMap( + id: UpdateId, + targetPath: File, + oldFile: File + ): File? { + LOG.debug("Downloading plugin update '$id' to [$targetPath] (blockmap download via '$oldFile)'") + return delegate.downloadViaBlockMap(id, targetPath, oldFile) + } + + } + + private class LoggingAndTracingPluginUpdateManager(private val delegate: PluginUpdateManager) : PluginUpdateManager { + override fun getUpdateById(id: UpdateId): PluginUpdateBean? { + LOG.debug("Getting plugin update '$id'") + return delegate.getUpdateById(id) + } + + override fun getUpdatesByVersionAndFamily( + xmlId: StringPluginId, + version: String, + family: ProductFamily + ): List { + LOG.debug("Getting plugin updates for '$xmlId:$version' (family: ${family.name.lowercase()})") + return delegate.getUpdatesByVersionAndFamily( + xmlId, + version, + family + ) + } + + override fun deleteUpdate(updateId: UpdateId): UpdateDeleteBean? { + LOG.debug("Deleting plugin update '$updateId'") + return delegate.deleteUpdate(updateId) + } + + override fun getIntellijUpdateMetadata(pluginId: PluginId, updateId: UpdateId): IntellijUpdateMetadata? { + LOG.debug("Retrieving plugin update metadata for plugin '$pluginId' and update '$updateId'") + return delegate.getIntellijUpdateMetadata(pluginId, updateId) + } + + override fun getIntellijUpdateMetadataBatch(updateIds: List>): Map { + val aggregatedUpdateIds: Map> = updateIds.groupBy { it.first } + .mapValues { (_, pairs: List>) -> + pairs.map { (_, updateId: UpdateId) -> updateId } + } + + val updateIdMsg = aggregatedUpdateIds.map { (plugin, updates) -> + "$plugin (${updates.size} updates): ${updates.joinToString { it.toString() }}" + }.joinToString("; ") + + LOG.debug("Retrieving plugin update metadata for plugins $updateIdMsg") + return delegate.getIntellijUpdateMetadataBatch(updateIds) + } + } +} + +fun PluginRepository.withLogging(): PluginRepository = LoggingAndTracingPluginRepository(this) \ No newline at end of file diff --git a/intellij-plugin-verifier/verifier-test/after-idea/src/main/java/com/intellij/json/JsonParserDefinition.java b/intellij-plugin-verifier/verifier-test/after-idea/src/main/java/com/intellij/json/JsonParserDefinition.java new file mode 100644 index 0000000000..7972f9a5af --- /dev/null +++ b/intellij-plugin-verifier/verifier-test/after-idea/src/main/java/com/intellij/json/JsonParserDefinition.java @@ -0,0 +1,4 @@ +package com.intellij.json; + +public class JsonParserDefinition { +} diff --git a/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/dependencies/resolution/BundledPluginDependencyFinderTest.kt b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/dependencies/resolution/BundledPluginDependencyFinderTest.kt new file mode 100644 index 0000000000..8890052526 --- /dev/null +++ b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/dependencies/resolution/BundledPluginDependencyFinderTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2000-2024 JetBrains s.r.o. and other contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package com.jetbrains.pluginverifier.dependencies.resolution + +import com.jetbrains.pluginverifier.plugin.DefaultPluginDetailsProvider +import com.jetbrains.pluginverifier.plugin.PluginDetailsCache +import com.jetbrains.pluginverifier.tests.BaseBytecodeTest +import com.jetbrains.pluginverifier.tests.mocks.MockSinglePluginDetailsCache +import com.jetbrains.pluginverifier.tests.mocks.bundledPlugin +import com.jetbrains.pluginverifier.tests.mocks.ideaPlugin +import com.jetbrains.pluginverifier.tests.mocks.withRootElement +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +private const val JSON_PLUGIN_ID = "com.intellij.modules.json" + + +class BundledPluginDependencyFinderTest : BaseBytecodeTest() { + private val jsonPlugin + get() = bundledPlugin { + id = JSON_PLUGIN_ID + artifactName = "json" + descriptorContent = ideaPlugin( + pluginId = JSON_PLUGIN_ID, + pluginName = "JSON", + vendor = "JetBrains s.r.o." + ).withRootElement() + } + + + @Test + fun `plugin that declares itself as a module is resolved`() { + val detailsProvider = DefaultPluginDetailsProvider(temporaryFolder.newFolder("extracted-plugins").toPath()) + val cache = MockSinglePluginDetailsCache(supportedPluginId = JSON_PLUGIN_ID, pluginDetailsProvider = detailsProvider) + + val ide = buildIdeWithBundledPlugins(bundledPlugins = listOf(jsonPlugin)) + val finder = BundledPluginDependencyFinder(ide, cache) + + val dependencyResult = finder.findPluginDependency(JSON_PLUGIN_ID, true) + assertTrue("Dependency must be 'DetailsProvided', but is '${dependencyResult.javaClass}'", dependencyResult is DependencyFinder.Result.DetailsProvided) + dependencyResult as DependencyFinder.Result.DetailsProvided + val pluginDetails = dependencyResult.pluginDetailsCacheResult + assertTrue("Plugin details must be 'Provided', but is '${pluginDetails.javaClass}'", pluginDetails is PluginDetailsCache.Result.Provided) + pluginDetails as PluginDetailsCache.Result.Provided + assertEquals(JSON_PLUGIN_ID, pluginDetails.pluginDetails.idePlugin.pluginId) + assertEquals(JSON_PLUGIN_ID, pluginDetails.pluginDetails.pluginInfo.pluginId) + } +} \ No newline at end of file diff --git a/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/dependencies/resolution/DefaultClassResolverProviderTest.kt b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/dependencies/resolution/DefaultClassResolverProviderTest.kt new file mode 100644 index 0000000000..307bf512d9 --- /dev/null +++ b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/dependencies/resolution/DefaultClassResolverProviderTest.kt @@ -0,0 +1,220 @@ +/* + * Copyright 2000-2024 JetBrains s.r.o. and other contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package com.jetbrains.pluginverifier.dependencies.resolution + +import com.jetbrains.plugin.structure.classes.resolvers.CompositeResolver +import com.jetbrains.plugin.structure.classes.resolvers.Resolver +import com.jetbrains.plugin.structure.intellij.plugin.PluginDependencyImpl +import com.jetbrains.pluginverifier.ide.IdeDescriptor +import com.jetbrains.pluginverifier.plugin.PluginDetails +import com.jetbrains.pluginverifier.resolution.DefaultClassResolverProvider +import com.jetbrains.pluginverifier.resolution.DefaultPluginDetailsBasedResolverProvider +import com.jetbrains.pluginverifier.resolution.PluginDetailsBasedResolverProvider +import com.jetbrains.pluginverifier.tests.BaseBytecodeTest +import com.jetbrains.pluginverifier.tests.mocks.MockDependencyFinder +import com.jetbrains.pluginverifier.tests.mocks.MockIdePlugin +import com.jetbrains.pluginverifier.tests.mocks.MockPackageFilter +import com.jetbrains.pluginverifier.tests.mocks.RuleBasedDependencyFinder +import com.jetbrains.pluginverifier.tests.mocks.RuleBasedDependencyFinder.Rule +import com.jetbrains.pluginverifier.tests.mocks.asm.publicClass +import com.jetbrains.pluginverifier.tests.mocks.getDetails +import org.intellij.lang.annotations.Language +import org.junit.Assert.assertTrue +import org.junit.Test + +class DefaultClassResolverProviderTest : BaseBytecodeTest() { + private val dependencyFinder = MockDependencyFinder() + + private val packageFilter = MockPackageFilter() + + private val plugin = MockIdePlugin( + pluginId = "somePlugin", + pluginVersion = "1.0" + ) + + private val pythonModuleDependency = PluginDependencyImpl("com.intellij.modules.python", false, true) + private val platformModuleDependency = PluginDependencyImpl("com.intellij.modules.platform", false, true) + + @Test + fun `legacy plugin without any dependencies resolves to IDEA Core plugin in Platform 192`() { + val ide = buildIdeWithBundledPlugins() + val ideDescriptor = IdeDescriptor.create(ide.idePath, defaultJdkPath = null, ideFileLock = null) + + val resolverProvider = DefaultClassResolverProvider(dependencyFinder, ideDescriptor, packageFilter) + + val classResolver = resolverProvider.provide(plugin.getDetails()) + // class from app.jar from mock IDE + assertTrue(classResolver.allResolver.containsClass("com/intellij/tasks/Task")) + } + + @Test + fun `legacy plugin without any dependencies resolves to IDEA Core plugin in Platform 243`() { + val ide = buildIdeWithBundledPlugins( + version = "IU-243.21565.193", + productInfo = productInfoJsonIU243, + hasModuleDescriptors = true + ) + val ideDescriptor = IdeDescriptor.create(ide.idePath, defaultJdkPath = null, ideFileLock = null) + + val resolverProvider = DefaultClassResolverProvider(dependencyFinder, ideDescriptor, packageFilter) + + val classResolver = resolverProvider.provide(plugin.getDetails()) + // class from app.jar from mock IDE + assertTrue(classResolver.allResolver.containsClass("com/intellij/tasks/Task")) + } + + @Test + fun `plugin with a bundled dependency unavailable in the Platform 243, but downloaded by custom details resolver provider`() { + val ide = buildIdeWithBundledPlugins( + version = "IU-243.21565.193", + productInfo = productInfoJsonIU243, + hasModuleDescriptors = true + ) + val ideDescriptor = IdeDescriptor.create(ide.idePath, defaultJdkPath = null, ideFileLock = null) + + val dependencyFinder = RuleBasedDependencyFinder.create( + ide, + Rule("com.intellij.modules.python", mockPythonPlugin), + Rule( + "com.intellij.modules.platform", mockIdeaCorePlugin, listOf( + publicClass("com/intellij/tasks/Task") + ) + ), + ) + + val defaultPluginDetailsBasedResolverProvider = DefaultPluginDetailsBasedResolverProvider() + val pluginDetailsResolverProvider = object : PluginDetailsBasedResolverProvider { + override fun getPluginResolver(pluginDependency: PluginDetails): Resolver { + return if (pluginDependency.idePlugin.pluginId == "com.intellij") { + with(pluginDependency.pluginClassesLocations) { + locationKeys + .flatMap { getResolvers(it) } + .let { resolvers -> CompositeResolver.create(resolvers) } + } + } else { + defaultPluginDetailsBasedResolverProvider.getPluginResolver(pluginDependency) + } + } + } + + + val resolverProvider = DefaultClassResolverProvider( + dependencyFinder, + ideDescriptor, + packageFilter, + pluginDetailsBasedResolverProvider = pluginDetailsResolverProvider + ) + + val plugin = this.plugin.copy(dependencies = listOf(pythonModuleDependency)) + + val classResolver = resolverProvider.provide(plugin.getDetails()) + // class from app.jar from mock IDE + assertTrue(classResolver.allResolver.containsClass("com/intellij/tasks/Task")) + } + + @Test + fun `plugin with a bundled dependency unavailable in the Platform 243, but downloaded`() { + val ide = buildIdeWithBundledPlugins( + version = "IU-243.21565.193", + productInfo = productInfoJsonIU243, + hasModuleDescriptors = true + ) + val ideDescriptor = IdeDescriptor.create(ide.idePath, defaultJdkPath = null, ideFileLock = null) + + val dependencyFinder = RuleBasedDependencyFinder.create( + ide, + Rule("com.intellij.modules.python", mockPythonPlugin), + Rule( + "com.intellij.modules.platform", mockIdeaCorePlugin, listOf( + publicClass("com/intellij/tasks/Task") + ), isBundledPlugin = true + ), + ) + + val resolverProvider = DefaultClassResolverProvider(dependencyFinder, ideDescriptor, packageFilter) + + val plugin = this.plugin.copy(dependencies = listOf(pythonModuleDependency)) + + val classResolver = resolverProvider.provide(plugin.getDetails()) + // class from app.jar from mock IDE + assertTrue(classResolver.allResolver.containsClass("com/intellij/tasks/Task")) + } + + @Test + fun `plugin with a bundled dependency unavailable in the Platform 223, but downloaded`() { + val ide = buildIdeWithBundledPlugins(version = "223.8836.41") + val ideDescriptor = IdeDescriptor.create(ide.idePath, defaultJdkPath = null, ideFileLock = null) + + val dependencyFinder = RuleBasedDependencyFinder.create( + ide, + Rule("com.intellij.modules.python", mockPythonPlugin), + Rule( + "com.intellij.modules.platform", mockIdeaCorePlugin, listOf( + publicClass("com/intellij/tasks/Task") + ), isBundledPlugin = true + ), + ) + + val resolverProvider = DefaultClassResolverProvider(dependencyFinder, ideDescriptor, packageFilter) + + val plugin = plugin.copy(dependencies = listOf(pythonModuleDependency)) + + val classResolver = resolverProvider.provide(plugin.getDetails()) + // class from app.jar from mock IDE + assertTrue(classResolver.allResolver.containsClass("com/intellij/tasks/Task")) + } + + private val mockPythonPlugin = MockIdePlugin( + pluginId = "Pythonid", + pluginVersion = "243.21565.193", + dependencies = listOf(platformModuleDependency), + definedModules = setOf("com.intellij.modules.python") + ) + + private val mockIdeaCorePlugin = MockIdePlugin( + pluginId = "com.intellij", + pluginVersion = "243.21565.193", + definedModules = setOf("com.intellij.modules.platform") + ) + + @Language("JSON") + private val productInfoJsonIU243 = """ + { + "name": "IntelliJ IDEA", + "version": "2024.3", + "buildNumber": "243.21565.193", + "productCode": "IU", + "envVarBaseName": "IDEA", + "dataDirectoryName": "IntelliJIdea2024.3", + "svgIconPath": "../bin/idea.svg", + "productVendor": "JetBrains", + "launch": [ + { + "os": "macOS", + "arch": "aarch64", + "launcherPath": "../MacOS/idea", + "javaExecutablePath": "../jbr/Contents/Home/bin/java", + "vmOptionsFilePath": "../bin/idea.vmoptions", + "bootClassPathJarNames": [ + "app.jar", + "idea_rt.jar" + ] + } + ], + "bundledPlugins": [], + "modules": [], + "layout": [ + { + "name": "com.intellij", + "kind": "plugin", + "classPath": [ + "lib/app.jar", + "lib/idea_rt.jar" + ] + } + ] + } + """.trimIndent() +} \ No newline at end of file diff --git a/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/BaseBytecodeTest.kt b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/BaseBytecodeTest.kt new file mode 100644 index 0000000000..dc2b1da897 --- /dev/null +++ b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/BaseBytecodeTest.kt @@ -0,0 +1,406 @@ +package com.jetbrains.pluginverifier.tests + +import com.jetbrains.plugin.structure.base.plugin.PluginCreationSuccess +import com.jetbrains.plugin.structure.base.utils.contentBuilder.ContentBuilder +import com.jetbrains.plugin.structure.base.utils.contentBuilder.buildDirectory +import com.jetbrains.plugin.structure.base.utils.contentBuilder.buildZipFile +import com.jetbrains.plugin.structure.base.utils.simpleName +import com.jetbrains.plugin.structure.ide.Ide +import com.jetbrains.plugin.structure.ide.IdeManager +import com.jetbrains.plugin.structure.intellij.plugin.IdePlugin +import com.jetbrains.plugin.structure.intellij.plugin.IdePluginManager +import com.jetbrains.pluginverifier.PluginVerificationResult +import com.jetbrains.pluginverifier.filtering.InternalApiUsageFilter +import com.jetbrains.pluginverifier.results.problems.CompatibilityProblem +import com.jetbrains.pluginverifier.tests.bytecode.Dumps.ComIntellijTasks_TaskRepositorySubtype +import com.jetbrains.pluginverifier.tests.mocks.IdeaPluginSpec +import com.jetbrains.pluginverifier.tests.mocks.PluginSpec +import com.jetbrains.pluginverifier.usages.internal.InternalApiUsage +import com.jetbrains.pluginverifier.verifiers.resolution.BinaryClassName +import com.jetbrains.pluginverifier.warnings.CompatibilityWarning +import kotlinx.metadata.KmClass +import kotlinx.metadata.jvm.JvmMetadataVersion +import kotlinx.metadata.jvm.KotlinClassMetadata +import net.bytebuddy.ByteBuddy +import net.bytebuddy.description.method.MethodDescription +import net.bytebuddy.dynamic.DynamicType +import net.bytebuddy.dynamic.loading.ClassLoadingStrategy +import net.bytebuddy.implementation.Implementation +import net.bytebuddy.implementation.bytecode.ByteCodeAppender +import net.bytebuddy.implementation.bytecode.ByteCodeAppender.Size +import net.bytebuddy.jar.asm.Label +import net.bytebuddy.jar.asm.MethodVisitor +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.objectweb.asm.Opcodes.* +import java.lang.reflect.Modifier +import java.lang.reflect.Type +import java.util.* +import kotlin.reflect.KClass + +abstract class BaseBytecodeTest { + @Rule + @JvmField + val temporaryFolder = TemporaryFolder() + + private lateinit var byteBuddy: ByteBuddy + + @Before + fun setUp() { + byteBuddy = ByteBuddy() + } + + protected fun verify(ide: Ide, idePlugin: IdePlugin): Set { + val apiUsageFilter = InternalApiUsageFilter() + + // Run verification + val verificationResult = VerificationRunner().runPluginVerification( + ide, idePlugin, + apiUsageFilters = listOf(apiUsageFilter) + ) as PluginVerificationResult.Verified + + // No warnings should be produced + assertEquals(emptySet(), verificationResult.compatibilityProblems) + assertEquals(emptySet(), verificationResult.compatibilityWarnings) + // JetBrains Plugin should not report internal usages. These are in the ignored usages + assertEquals(0, verificationResult.internalApiUsages.size) + return verificationResult.ignoredInternalApiUsages.keys + } + + protected fun assertVerified(spec: VerificationSpec.() -> Unit) = + runPluginVerification(spec) as PluginVerificationResult.Verified + + internal fun prepareUsage( + pluginSpec: IdeaPluginSpec, + dynamicTypeBuilder: () -> DynamicType.Unloaded<*> + ): IdePlugin { + return buildIdePlugin(pluginSpec) { + usageClass(dynamicTypeBuilder()) + } + } + + internal fun prepareUsage( + pluginSpec: IdeaPluginSpec, + classFileName: String, + classFileBinaryContent: ByteArray + ): IdePlugin { + return buildIdePlugin(pluginSpec) { + dir("plugin") { + file("$classFileName.class", classFileBinaryContent) + } + } + } + + protected fun prepareIdeWithApi(platformApiClassTypeBuilder: () -> DynamicType.Unloaded<*>): Ide { + return buildIdeWithBundledPlugins { + dir("com") { + dir("intellij") { + dir("openapi") { + apiClass(platformApiClassTypeBuilder()) + } + } + } + } + } + + private fun ContentBuilder.usageClass(dynamicType: DynamicType.Unloaded<*>) { + val className = dynamicType.typeDescription.simpleName + dir("usage") { + file("$className.class", dynamicType.bytes) + } + } + + private fun ContentBuilder.apiClass(dynamicType: DynamicType.Unloaded<*>) { + val className = dynamicType.typeDescription.simpleName + file("$className.class", dynamicType.bytes) + } + + @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") + private fun load(classDynamicType: DynamicType.Unloaded, classLoader: ClassLoader): Class { + return classDynamicType.load(classLoader, ClassLoadingStrategy.Default.WRAPPER).loaded + } + + protected fun String.construct() = byteBuddy + .subclass(Object::class.java) + .name(this) + + @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") + protected fun String.constructWithMethod( + method: String, + returnType: Type, + modifier: Int = Modifier.PUBLIC + ): DynamicType.Builder.MethodDefinition.ParameterDefinition.Initial { + return construct() + .defineMethod(method, returnType, modifier) + } + + protected fun buildIdeWithBundledPlugins( + includeKotlinStdLib: Boolean = false, + javaPluginClassesBuilder: (ContentBuilder).() -> Unit + ): Ide { + val ideaDirectory = buildDirectory(temporaryFolder.newFolder("idea").toPath()) { + file("build.txt", "IU-192.1") + dir("lib") { + zip("idea.jar") { + dir("META-INF") { + file("plugin.xml") { + """ + + com.intellij + IDEA CORE + 1.0 + + + """.trimIndent() + } + } + } + if (includeKotlinStdLib) { + findKotlinStdLib().apply { + file(simpleName, this) + } + } + } + dir("plugins") { + dir("java") { + dir("lib") { + zip("java.jar") { + dir("META-INF") { + file("plugin.xml") { + """ + + com.intellij.java + + + """.trimIndent() + } + } + + //Generate content of Java plugin. + javaPluginClassesBuilder() + } + } + } + } + } + + // Fast assert IDE is fine + val ide = IdeManager.createManager().createIde(ideaDirectory) + assertEquals("IU-192.1", ide.version.asString()) + + val javaPlugin = ide.bundledPlugins.find { it.pluginId == "com.intellij.java" }!! + assertEquals("com.intellij.java", javaPlugin.pluginId) + assertEquals(setOf("com.intellij.modules.java"), javaPlugin.definedModules) + + return ide + } + + /** + * Builds an instance of the IDE with specified bundled plugins. + * + * By default, this IDE contains the `com.intellij` plugin available in the `lib/idea.jar`. + * + * @param bundledPlugins List of plugins to include in the `plugins` directory. + * @param additionalCorePlugins Additional plugins to include in the `lib` directory of the IDE. + * @param includeKotlinStdLib Whether to include the Kotlin standard library. + * @param productInfo JSON contents of `product-info.json` + * @param version The version string of this IDE. + * @param hasModuleDescriptors If the `module-descriptors.jar` should be created as an empty file. + * @return The created instance of the IDE. + */ + internal fun buildIdeWithBundledPlugins( + bundledPlugins: List = emptyList(), + additionalCorePlugins: List = emptyList(), + includeKotlinStdLib: Boolean = false, + productInfo: String? = null, + version: String = "IU-192.1", + hasModuleDescriptors: Boolean = false + ): Ide { + val ideaDirectory = buildDirectory(temporaryFolder.newFolder("idea").toPath()) { + file("build.txt", version) + productInfo?.let { + file("product-info.json", it) + } + dir("lib") { + zip("idea_rt.jar") { + dir("META-INF") { + file("plugin.xml") { + """ + + com.intellij + IDEA CORE + 1.0 + + + """.trimIndent() + } + } + } + additionalCorePlugins.forEach { plugin -> + plugin.buildJar(this) + } + if (includeKotlinStdLib) { + findKotlinStdLib().apply { + file(simpleName, this) + } + } + /* A JAR with at least one file in the `com.intellij` package. + This mimics a regular IDE in order to align with unit tests. + */ + zip("app.jar") { + dir("com") { + dir("intellij") { + dir("tasks") { + file("Task.class", ComIntellijTasks_TaskRepositorySubtype()) + } + } + } + } + } + dir("plugins") { + bundledPlugins.forEach { plugin -> + plugin.build(this) + } + } + if (hasModuleDescriptors) { + dir("modules") { + zip("module-descriptors.jar") { /* empty file */ } + } + } + } + + // Fast assert IDE is fine + val ide = IdeManager.createManager().createIde(ideaDirectory) + assertEquals(version, ide.version.asString()) + + return ide + } + + private fun buildIdePlugin( + ideaPluginSpec: IdeaPluginSpec = IdeaPluginSpec("com.intellij", "JetBrains s.r.o."), + pluginClassesContentBuilder: (ContentBuilder).() -> Unit + ): IdePlugin { + val pluginFile = buildZipFile(temporaryFolder.newFile("plugin.jar").toPath()) { + pluginClassesContentBuilder() + + val additionalDepends = ideaPluginSpec.dependencies.joinToString("\n") { "$it" } + + dir("META-INF") { + file("plugin.xml") { + """ + + ${ideaPluginSpec.id} + someName + someVersion + ${ideaPluginSpec.vendor} + this description is looooooooooong enough + these change-notes are looooooooooong enough + + com.intellij.modules.java + $additionalDepends + + """.trimIndent() + } + } + } + + return (IdePluginManager.createManager().createPlugin(pluginFile) as PluginCreationSuccess).plugin + } + + protected fun kotlinMetadata(configure: KmClass.() -> Unit) = KmClass().apply { + configure() + }.let { + KotlinClassMetadata + .Class(it, JvmMetadataVersion.LATEST_STABLE_SUPPORTED, 0) + .write() + } + + protected fun String.randomize(): String { + return buildString { + append(this@randomize) + append("_") + append(UUID.randomUUID().toString().replace("-", "")) + } + } + + @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") + protected fun DynamicType.Unloaded.newInstance(classLoader: ClassLoader = this::class.java.classLoader) = + load(this, classLoader) + .getDeclaredConstructor().newInstance() + + /** + * Creates a new instance of `callee` and invokes a `fieldName` while assigning a fixed value on this instance. + * This occurs within a method of the `caller`. + * The field value is hardwired to an integer + */ + protected class DirectFieldAccess( + private val caller: BinaryClassName, + private val callee: BinaryClassName, + private val fieldName: String, + private val fieldValue: Int + ) : ByteCodeAppender { + + override fun apply( + methodVisitor: MethodVisitor, + implementationContext: Implementation.Context, + instrumentedMethod: MethodDescription + ): Size { + with(methodVisitor) { + val methodBeginning = Label() + visitLabel(methodBeginning) + visitTypeInsn(NEW, callee) + visitInsn(DUP) + visitMethodInsn(INVOKESPECIAL, callee, "", "()V", false) + visitVarInsn(ASTORE, 1) + val instanceScopeBeginning = Label() + visitLabel(instanceScopeBeginning) + visitVarInsn(ALOAD, 1) + visitIntInsn(BIPUSH, fieldValue) + visitFieldInsn(PUTFIELD, callee, fieldName, "I") + visitIntInsn(BIPUSH, fieldValue) + visitInsn(IRETURN) + val methodEnd = Label() + visitLabel(methodEnd) + visitLocalVariable("this", "L$caller;", null, methodBeginning, methodEnd, 0) + visitLocalVariable("instance", "L$callee;", null, instanceScopeBeginning, methodEnd, 1) + } + return Size(2, 2) + } + + val implementation: Implementation + get() = Implementation.Simple(this) + } + + protected fun assertContains( + compatibilityProblems: Collection, + compatibilityProblemClass: KClass, + fullDescription: String? = null + ) { + val problems = compatibilityProblems.filterIsInstance(compatibilityProblemClass.java) + if (problems.isEmpty()) { + fail("There are no compatibility problems of class [${compatibilityProblemClass.qualifiedName}]") + return + } + if (fullDescription == null) { + return + } + val problemsWithMessage = problems.filter { it.fullDescription == fullDescription } + if (problemsWithMessage.isEmpty()) { + fail("Compatibility problems has ${problems.size} problem(s) of class [${compatibilityProblemClass.qualifiedName}], " + + "but none has a full description '$fullDescription'. " + + "Found [" + problems.joinToString { it.fullDescription } + "]" + ) + return + } + } + + @Throws(AssertionError::class) + protected fun PluginVerificationResult.Verified.assertNoCompatibilityProblems() = with(compatibilityProblems) { + if (isNotEmpty()) { + fail("Expected no problems, but got $size problem(s): " + joinToString { it.fullDescription }) + } + } +} \ No newline at end of file diff --git a/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/CompatibilityProblemChangeListTest.kt b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/CompatibilityProblemChangeListTest.kt new file mode 100644 index 0000000000..1159c59457 --- /dev/null +++ b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/CompatibilityProblemChangeListTest.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2000-2024 JetBrains s.r.o. and other contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package com.jetbrains.pluginverifier.tests + +import com.jetbrains.pluginverifier.analysis.CompatibilityProblemChangeList +import com.jetbrains.pluginverifier.results.problems.PackageNotFoundProblem +import org.junit.Assert.assertEquals +import org.junit.Test + +class CompatibilityProblemChangeListTest { + @Test + fun `problems are added and removed`() { + val pluginPackageNotFoundProblem = PackageNotFoundProblem("com.intellij.json", emptySet()) + val otherPackageNotFoundProblem = PackageNotFoundProblem("com.intellij.ide", emptySet()) + val changeList = CompatibilityProblemChangeList() + changeList += otherPackageNotFoundProblem + changeList -= pluginPackageNotFoundProblem + + assertEquals(1, changeList.problems.size) + assertEquals(1, changeList.addedProblems.size) + assertEquals(1, changeList.removedProblems.size) + } + + @Test + fun `same problems are not duplicated`() { + val pluginPackageNotFoundProblem = PackageNotFoundProblem("com.intellij.json", emptySet()) + val pluginPackageNotFoundProblemDuplicate = PackageNotFoundProblem("com.intellij.json", emptySet()) + + val changeList = CompatibilityProblemChangeList() + changeList += pluginPackageNotFoundProblem + changeList += pluginPackageNotFoundProblemDuplicate + + assertEquals(1, changeList.problems.size) + assertEquals(1, changeList.addedProblems.size) + assertEquals(0, changeList.removedProblems.size) + } +} \ No newline at end of file diff --git a/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/ExtractedJsonPluginAnalyzerTest.kt b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/ExtractedJsonPluginAnalyzerTest.kt new file mode 100644 index 0000000000..648146f8d3 --- /dev/null +++ b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/ExtractedJsonPluginAnalyzerTest.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2000-2024 JetBrains s.r.o. and other contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package com.jetbrains.pluginverifier.tests + +import com.jetbrains.plugin.structure.classes.resolvers.FileOrigin +import com.jetbrains.plugin.structure.intellij.version.IdeVersion +import com.jetbrains.pluginverifier.analysis.ExtractedJsonPluginAnalyzer +import com.jetbrains.pluginverifier.results.location.ClassLocation +import com.jetbrains.pluginverifier.results.modifiers.Modifiers +import com.jetbrains.pluginverifier.results.modifiers.Modifiers.Modifier.PUBLIC +import com.jetbrains.pluginverifier.results.problems.ClassNotFoundProblem +import com.jetbrains.pluginverifier.results.problems.CompatibilityProblem +import com.jetbrains.pluginverifier.results.problems.UndeclaredPluginDependencyProblem +import com.jetbrains.pluginverifier.results.reference.ClassReference +import com.jetbrains.pluginverifier.tests.mocks.MockIde +import com.jetbrains.pluginverifier.tests.mocks.MockIdePlugin +import com.jetbrains.pluginverifier.verifiers.resolution.toBinaryClassName +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ExtractedJsonPluginAnalyzerTest { + private val jsonPluginAnalyzer = ExtractedJsonPluginAnalyzer() + private val fileOrigin = object : FileOrigin { + override val parent: FileOrigin? = null + } + + private val targetIde = MockIde(IdeVersion.createIdeVersion("IC-243.16128")) + + private val plugin = MockIdePlugin() + private val usage = ClassLocation("plugin.Usage", signature = null, Modifiers.of(PUBLIC), fileOrigin) + + @Test + fun `class references an undeclared JSON package`() { + val compatibilityProblems = mutableSetOf( + ClassNotFoundProblem(ClassReference("com.intellij.json.JsonElementType".toBinaryClassName()), usage) + ) + val problems = jsonPluginAnalyzer.analyze(targetIde, plugin, compatibilityProblems) + assertEquals(1, problems.size) + val problem = problems.first() + assertTrue(problem is UndeclaredPluginDependencyProblem) + problem as UndeclaredPluginDependencyProblem + assertEquals( + "Plugin 'com.intellij.modules.json' is not declared in the plugin descriptor as a dependency for " + + "class com.intellij.json.JsonElementType. " + + "JSON support has been extracted to a separate plugin.", + problem.fullDescription + ) + } + + @Test + fun `class references an another class that is not explicitly removed but the parent package is`() { + val explicitlyRemovedClassInGeneralPackage = "com.intellij.json.JsonLexer" + val compatibilityProblems = mutableSetOf( + ClassNotFoundProblem(ClassReference(explicitlyRemovedClassInGeneralPackage.toBinaryClassName()), usage) + ) + val problems = jsonPluginAnalyzer.analyze(targetIde, plugin, compatibilityProblems) + assertEquals(1, problems.size) + val problem = problems.first() + assertTrue(problem is UndeclaredPluginDependencyProblem) + problem as UndeclaredPluginDependencyProblem + assertEquals( + "Plugin 'com.intellij.modules.json' is not declared in the plugin descriptor as a dependency for " + + "class com.intellij.json.JsonLexer. " + + "JSON support has been extracted to a separate plugin.", + problem.fullDescription + ) + } + +} \ No newline at end of file diff --git a/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/JsonPluginUsageTest.kt b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/JsonPluginUsageTest.kt new file mode 100644 index 0000000000..9e228497e6 --- /dev/null +++ b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/JsonPluginUsageTest.kt @@ -0,0 +1,179 @@ +/* + * Copyright 2000-2024 JetBrains s.r.o. and other contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package com.jetbrains.pluginverifier.tests + +import com.jetbrains.plugin.structure.base.utils.exists +import com.jetbrains.plugin.structure.ide.Ide +import com.jetbrains.pluginverifier.results.problems.PackageNotFoundProblem +import com.jetbrains.pluginverifier.results.problems.UndeclaredPluginDependencyProblem +import com.jetbrains.pluginverifier.tests.bytecode.Dumps +import com.jetbrains.pluginverifier.tests.mocks.IdeaPluginSpec +import com.jetbrains.pluginverifier.tests.mocks.bundledPlugin +import com.jetbrains.pluginverifier.tests.mocks.ideaPlugin +import com.jetbrains.pluginverifier.tests.mocks.withRootElement +import org.junit.Assert.assertEquals +import org.junit.Test +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths + +private const val JSON_PLUGIN_ID = "com.intellij.modules.json" + +class JsonPluginUsageTest : BaseBytecodeTest() { + private val pluginSpec = IdeaPluginSpec("com.intellij.plugin", "JetBrains s.r.o.") + + private val jsonPlugin + get() = bundledPlugin { + id = JSON_PLUGIN_ID + artifactName = "json" + descriptorContent = ideaPlugin( + pluginId = JSON_PLUGIN_ID, + pluginName = "JSON", + vendor = "JetBrains s.r.o." + ).withRootElement() + classContentBuilder = { + dirs("com/intellij/json") { + file("JsonParserDefinition.class", JsonParserDefinition()) + } + } + } + + @Test + fun `plugin uses JSON classes but they are not available in the IDE`() { + assertVerified { + ide = buildIdeWithBundledPlugins {} + plugin = prepareUsage(pluginSpec, "JsonPluginUsage", Dumps.JsonPluginUsage()) + kotlin = false + }.run { + with(compatibilityProblems) { + assertEquals(1, size) + assertContains(this, PackageNotFoundProblem::class) + } + } + } + + @Test + fun `plugin uses JSON classes, JSON plugin is declared, but without any classes`() { + val targetIde = buildIdeWithBundledPlugins(listOf(jsonPlugin)) + assertEquals(2, targetIde.bundledPlugins.size) + + assertVerified { + ide = targetIde + plugin = prepareUsage(pluginSpec, "JsonPluginUsage", Dumps.JsonPluginUsage()) + kotlin = false + }.run { + with(compatibilityProblems) { + assertContains(this, PackageNotFoundProblem::class) + } + } + } + + @Test + fun `plugin uses JSON classes, JSON plugin is declared and includes classes`() { + val targetIde = buildIdeWithBundledPlugins(additionalCorePlugins = listOf(jsonPlugin)) + assertEquals(2, targetIde.bundledPlugins.size) + + assertVerified { + ide = targetIde + plugin = prepareUsage(pluginSpec, "JsonPluginUsage", Dumps.JsonPluginUsage()) + kotlin = false + }.run { + with(compatibilityProblems) { + assertEquals(0, size) + } + } + } + + @Test + fun `plugin uses JSON classes in the 243 IDE, but the JSON plugin dependency is not declared`() { + val targetIde = buildIdeWithBundledPlugins( + bundledPlugins = listOf(jsonPlugin), + productInfo = onlyJsonPluginProductInfoValue, + version = "IC-243.16128", + hasModuleDescriptors = true + ) + assertEquals(2, targetIde.bundledPlugins.size) + targetIde.assertHasBundledPluginWithPath(Paths.get("plugins/json/lib/json.jar")) + + assertVerified { + ide = targetIde + plugin = prepareUsage(pluginSpec, "JsonPluginUsage", Dumps.JsonPluginUsage()) + kotlin = false + }.run { + with(compatibilityProblems) { + assertEquals(1, size) + assertContains(this, UndeclaredPluginDependencyProblem::class) + } + } + } + + @Test + fun `plugin uses JSON classes in the 243 IDE and declares JSON plugin dependency`() { + val targetIde = buildIdeWithBundledPlugins( + bundledPlugins = listOf(jsonPlugin), + productInfo = onlyJsonPluginProductInfoValue, + version = "IC-243.16128", + hasModuleDescriptors = true + ) + assertEquals(2, targetIde.bundledPlugins.size) + targetIde.assertHasBundledPluginWithPath(Paths.get("plugins/json/lib/json.jar")) + + + val pluginSpec = IdeaPluginSpec("com.intellij.plugin", "JetBrains s.r.o.", dependencies = listOf(JSON_PLUGIN_ID)) + + assertVerified { + ide = targetIde + plugin = prepareUsage(pluginSpec, "JsonPluginUsage", Dumps.JsonPluginUsage()) + kotlin = false + }.run { + assertNoCompatibilityProblems() + } + } + + private fun Ide.assertHasBundledPluginWithPath(path: Path) { + val hasPlugin = bundledPlugins.any { + it.originalFile?.endsWith(path) ?: false + } + if (!hasPlugin) throw AssertionError("IDE does not contain plugin that has a path ending with '$path'") + } + + fun findAfterIdeaBuildClassPath(): Path { + val directory = Paths.get("after-idea", "build", "classes", "java", "main") + return if (directory.exists()) { + directory + } else { + Paths.get("verifier-test").resolve(directory).also { check(it.exists()) } + } + } + + private fun JsonParserDefinition(): ByteArray { + return findAfterIdeaBuildClassPath() + .resolve("com/intellij/json/JsonParserDefinition.class") + .let { Files.readAllBytes(it) } + } + + private val onlyJsonPluginProductInfoValue = """ + { + "name": "IntelliJ IDEA", + "version": "2024.3", + "buildNumber": "243.16128", + "productCode": "IC", + "dataDirectoryName": "IntelliJIdea2024.3", + "svgIconPath": "bin/idea.svg", + "productVendor": "JetBrains", + "bundledPlugins": [], + "modules": [], + "layout": [ + { + "name": "com.intellij.modules.json", + "kind": "plugin", + "classPath": [ + "plugins/json/lib/json.jar" + ] + } + ] + } + """.trimIndent() +} \ No newline at end of file diff --git a/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/KotlinInternalModifierUsageTest.kt b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/KotlinInternalModifierUsageTest.kt index d3225a165a..f49690e32b 100644 --- a/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/KotlinInternalModifierUsageTest.kt +++ b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/KotlinInternalModifierUsageTest.kt @@ -1,77 +1,34 @@ package com.jetbrains.pluginverifier.tests -import com.jetbrains.plugin.structure.base.plugin.PluginCreationSuccess -import com.jetbrains.plugin.structure.base.utils.contentBuilder.ContentBuilder -import com.jetbrains.plugin.structure.base.utils.contentBuilder.buildDirectory -import com.jetbrains.plugin.structure.base.utils.contentBuilder.buildZipFile -import com.jetbrains.plugin.structure.base.utils.simpleName -import com.jetbrains.plugin.structure.ide.Ide -import com.jetbrains.plugin.structure.ide.IdeManager -import com.jetbrains.plugin.structure.intellij.plugin.IdePlugin -import com.jetbrains.plugin.structure.intellij.plugin.IdePluginManager -import com.jetbrains.pluginverifier.PluginVerificationResult -import com.jetbrains.pluginverifier.filtering.InternalApiUsageFilter -import com.jetbrains.pluginverifier.results.problems.CompatibilityProblem import com.jetbrains.pluginverifier.tests.bytecode.Dumps import com.jetbrains.pluginverifier.tests.bytecode.JavaDumps import com.jetbrains.pluginverifier.tests.mocks.IdeaPluginSpec -import com.jetbrains.pluginverifier.usages.internal.InternalApiUsage import com.jetbrains.pluginverifier.usages.internal.kotlin.KtInternalClassUsage import com.jetbrains.pluginverifier.usages.internal.kotlin.KtInternalFieldUsage import com.jetbrains.pluginverifier.usages.internal.kotlin.KtInternalMethodUsage -import com.jetbrains.pluginverifier.verifiers.resolution.BinaryClassName import com.jetbrains.pluginverifier.verifiers.resolution.toBinaryClassName -import com.jetbrains.pluginverifier.warnings.CompatibilityWarning -import kotlinx.metadata.KmClass import kotlinx.metadata.KmClassifier import kotlinx.metadata.KmFunction import kotlinx.metadata.KmProperty import kotlinx.metadata.KmType import kotlinx.metadata.Visibility -import kotlinx.metadata.jvm.JvmMetadataVersion import kotlinx.metadata.jvm.JvmMethodSignature -import kotlinx.metadata.jvm.KotlinClassMetadata import kotlinx.metadata.jvm.signature import kotlinx.metadata.visibility -import net.bytebuddy.ByteBuddy -import net.bytebuddy.description.method.MethodDescription -import net.bytebuddy.dynamic.DynamicType -import net.bytebuddy.dynamic.loading.ClassLoadingStrategy import net.bytebuddy.implementation.FixedValue -import net.bytebuddy.implementation.Implementation import net.bytebuddy.implementation.MethodDelegation -import net.bytebuddy.implementation.bytecode.ByteCodeAppender -import net.bytebuddy.implementation.bytecode.ByteCodeAppender.Size -import net.bytebuddy.jar.asm.Label -import net.bytebuddy.jar.asm.MethodVisitor import net.bytebuddy.matcher.ElementMatchers.named import org.junit.Assert.assertEquals -import org.junit.Before import org.junit.Ignore -import org.junit.Rule import org.junit.Test -import org.junit.rules.TemporaryFolder -import org.objectweb.asm.Opcodes.* import java.lang.reflect.Modifier -import java.lang.reflect.Type -import java.util.* private const val internalApiServiceClassName = "com.intellij.openapi.InternalApiService" private const val usageClassName = "usage.Usage" -class KotlinInternalModifierUsageTest { - @Rule - @JvmField - val temporaryFolder = TemporaryFolder() - - private lateinit var byteBuddy: ByteBuddy - - @Before - fun setUp() { - byteBuddy = ByteBuddy() - } +class KotlinInternalModifierUsageTest : BaseBytecodeTest() { private fun getInternalMethodUsageMsg(caller: String, callee: String) = "Internal method $callee.internalFortyTwo() : int " + @@ -275,251 +232,14 @@ class KotlinInternalModifierUsageTest { } } - private fun verify(ide: Ide, idePlugin: IdePlugin): Set { - val apiUsageFilter = InternalApiUsageFilter() - - // Run verification - val verificationResult = VerificationRunner().runPluginVerification( - ide, idePlugin, - apiUsageFilters = listOf(apiUsageFilter) - ) as PluginVerificationResult.Verified - - // No warnings should be produced - assertEquals(emptySet(), verificationResult.compatibilityProblems) - assertEquals(emptySet(), verificationResult.compatibilityWarnings) - // JetBrains Plugin should not report internal usages. These are in the ignored usages - assertEquals(0, verificationResult.internalApiUsages.size) - return verificationResult.ignoredInternalApiUsages.keys - } - - private fun assertVerified(spec: VerificationSpec.() -> Unit) = - runPluginVerification(spec) as PluginVerificationResult.Verified - - private fun prepareUsage( - pluginSpec: IdeaPluginSpec, - dynamicTypeBuilder: () -> DynamicType.Unloaded<*> - ): IdePlugin { - return buildIdePlugin(pluginSpec) { - usageClass(dynamicTypeBuilder()) - } - } - - private fun prepareUsage( - pluginSpec: IdeaPluginSpec, - classFileName: String, - classFileBinaryContent: ByteArray - ): IdePlugin { - return buildIdePlugin(pluginSpec) { - dir("plugin") { - file("$classFileName.class", classFileBinaryContent) - } - } - } - - private fun prepareIdeWithApi(platformApiClassTypeBuilder: () -> DynamicType.Unloaded<*>): Ide { - return buildIdeWithBundledPlugins { - dir("com") { - dir("intellij") { - dir("openapi") { - apiClass(platformApiClassTypeBuilder()) - } - } - } - } - } - - private fun ContentBuilder.usageClass(dynamicType: DynamicType.Unloaded<*>) { - val className = dynamicType.typeDescription.simpleName - dir("usage") { - file("$className.class", dynamicType.bytes) - } - } - - private fun ContentBuilder.apiClass(dynamicType: DynamicType.Unloaded<*>) { - val className = dynamicType.typeDescription.simpleName - file("$className.class", dynamicType.bytes) - } - - @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") - private fun load(classDynamicType: DynamicType.Unloaded, classLoader: ClassLoader): Class { - return classDynamicType.load(classLoader, ClassLoadingStrategy.Default.WRAPPER).loaded - } - - private fun String.construct() = byteBuddy - .subclass(Object::class.java) - .name(this) - - @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") - private fun String.constructWithMethod( - method: String, - returnType: Type, - modifier: Int = Modifier.PUBLIC - ): DynamicType.Builder.MethodDefinition.ParameterDefinition.Initial { - return construct() - .defineMethod(method, returnType, modifier) - } - - private fun buildIdeWithBundledPlugins( - includeKotlinStdLib: Boolean = false, - javaPluginClassesBuilder: (ContentBuilder).() -> Unit - ): Ide { - val ideaDirectory = buildDirectory(temporaryFolder.newFolder("idea").toPath()) { - file("build.txt", "IU-192.1") - dir("lib") { - zip("idea.jar") { - dir("META-INF") { - file("plugin.xml") { - """ - - com.intellij - IDEA CORE - 1.0 - - - """.trimIndent() - } - } - } - if (includeKotlinStdLib) { - findKotlinStdLib().apply { - file(simpleName, this) - } - } - } - dir("plugins") { - dir("java") { - dir("lib") { - zip("java.jar") { - dir("META-INF") { - file("plugin.xml") { - """ - - com.intellij.java - - - """.trimIndent() - } - } - - //Generate content of Java plugin. - javaPluginClassesBuilder() - } - } - } - } - } - - // Fast assert IDE is fine - val ide = IdeManager.createManager().createIde(ideaDirectory) - assertEquals("IU-192.1", ide.version.asString()) - - val javaPlugin = ide.bundledPlugins.find { it.pluginId == "com.intellij.java" }!! - assertEquals("com.intellij.java", javaPlugin.pluginId) - assertEquals(setOf("com.intellij.modules.java"), javaPlugin.definedModules) - - return ide - } - - private fun buildIdePlugin( - ideaPluginSpec: IdeaPluginSpec = IdeaPluginSpec("com.intellij", "JetBrains s.r.o."), - pluginClassesContentBuilder: (ContentBuilder).() -> Unit - ): IdePlugin { - val pluginFile = buildZipFile(temporaryFolder.newFile("plugin.jar").toPath()) { - pluginClassesContentBuilder() - - dir("META-INF") { - file("plugin.xml") { - """ - - ${ideaPluginSpec.id} - someName - someVersion - ""${ideaPluginSpec.vendor}"" - this description is looooooooooong enough - these change-notes are looooooooooong enough - - com.intellij.modules.java - - """.trimIndent() - } - } - } - - return (IdePluginManager.createManager().createPlugin(pluginFile) as PluginCreationSuccess).plugin - } - /** * Generate random API class name to prevent naming clashes in ByteBuddy. */ - private fun generateInternalApiServiceClassName() = internalApiServiceClassName.randomize() + protected fun generateInternalApiServiceClassName() = internalApiServiceClassName.randomize() /** * Generate random API Usage class to prevent naming clashes in ByteBuddy. */ - private fun generateUsageClassName() = usageClassName.randomize() - - private fun kotlinMetadata(configure: KmClass.() -> Unit) = KmClass().apply { - configure() - }.let { - KotlinClassMetadata - .Class(it, JvmMetadataVersion.LATEST_STABLE_SUPPORTED, 0) - .write() - } - - private fun String.randomize(): String { - return buildString { - append(this@randomize) - append("_") - append(UUID.randomUUID().toString().replace("-", "")) - } - } - - @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") - private fun DynamicType.Unloaded.newInstance(classLoader: ClassLoader = this::class.java.classLoader) = - load(this, classLoader) - .getDeclaredConstructor().newInstance() - - /** - * Creates a new instance of `callee` and invokes a `fieldName` while assigning a fixed value on this instance. - * This occurs within a method of the `caller`. - * The field value is hardwired to an integer - */ - private class DirectFieldAccess( - private val caller: BinaryClassName, - private val callee: BinaryClassName, - private val fieldName: String, - private val fieldValue: Int - ) : ByteCodeAppender { - - override fun apply( - methodVisitor: MethodVisitor, - implementationContext: Implementation.Context, - instrumentedMethod: MethodDescription - ): Size { - with(methodVisitor) { - val methodBeginning = Label() - visitLabel(methodBeginning) - visitTypeInsn(NEW, callee) - visitInsn(DUP) - visitMethodInsn(INVOKESPECIAL, callee, "", "()V", false) - visitVarInsn(ASTORE, 1) - val instanceScopeBeginning = Label() - visitLabel(instanceScopeBeginning) - visitVarInsn(ALOAD, 1) - visitIntInsn(BIPUSH, fieldValue) - visitFieldInsn(PUTFIELD, callee, fieldName, "I") - visitIntInsn(BIPUSH, fieldValue) - visitInsn(IRETURN) - val methodEnd = Label() - visitLabel(methodEnd) - visitLocalVariable("this", "L$caller;", null, methodBeginning, methodEnd, 0) - visitLocalVariable("instance", "L$callee;", null, instanceScopeBeginning, methodEnd, 1) - } - return Size(2, 2) - } - - val implementation: Implementation - get() = Implementation.Simple(this) - } + protected fun generateUsageClassName() = usageClassName.randomize() } diff --git a/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/bytecode/Dumps.kt b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/bytecode/Dumps.kt index 8d1148ff23..b150c8816d 100644 --- a/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/bytecode/Dumps.kt +++ b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/bytecode/Dumps.kt @@ -338,5 +338,116 @@ object Dumps { return classWriter.toByteArray() } + @Suppress("TestFunctionName") + @Throws(Exception::class) + fun JsonPluginUsage(): ByteArray { + val classWriter = ClassWriter(0) + var methodVisitor: MethodVisitor + var annotationVisitor0: AnnotationVisitor + + classWriter.visit( + V17, + ACC_PUBLIC or ACC_FINAL or ACC_SUPER, + "plugin/JsonPluginUsage", + null, + "java/lang/Object", + null + ) + + run { + annotationVisitor0 = classWriter.visitAnnotation("Lcom/intellij/openapi/components/Service;", true) + run { + val annotationVisitor1 = annotationVisitor0.visitArray("value") + annotationVisitor1.visitEnum(null, "Lcom/intellij/openapi/components/Service\$Level;", "PROJECT") + annotationVisitor1.visitEnd() + } + annotationVisitor0.visitEnd() + } + run { + annotationVisitor0 = classWriter.visitAnnotation("Lkotlin/Metadata;", true) + annotationVisitor0.visit("mv", intArrayOf(2, 0, 0)) + annotationVisitor0.visit("k", 1) + annotationVisitor0.visit("xi", 48) + run { + val annotationVisitor1 = annotationVisitor0.visitArray("d1") + annotationVisitor1.visit( + null, + "\u0000\u0012\n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0002\u0008\u0003\n\u0002\u0010\u0002\n\u0000\u0008\u0007\u0018\u00002\u00020\u0001B\u0007\u00a2\u0006\u0004\u0008\u0002\u0010\u0003J\u0006\u0010\u0004\u001a\u00020\u0005\u00a8\u0006\u0006" + ) + annotationVisitor1.visitEnd() + } + run { + val annotationVisitor1 = annotationVisitor0.visitArray("d2") + annotationVisitor1.visit(null, "Lplugin/JsonPluginUsage;") + annotationVisitor1.visit(null, "") + annotationVisitor1.visit(null, "") + annotationVisitor1.visit(null, "()V") + annotationVisitor1.visit(null, "serve") + annotationVisitor1.visit(null, "") + annotationVisitor1.visit(null, "kotlin-plugin-idea-plugin") + annotationVisitor1.visitEnd() + } + annotationVisitor0.visitEnd() + } + classWriter.visitInnerClass( + "com/intellij/openapi/components/Service\$Level", + "com/intellij/openapi/components/Service", + "Level", + ACC_PUBLIC or ACC_FINAL or ACC_STATIC or ACC_ENUM + ) + + run { + methodVisitor = classWriter.visitMethod(ACC_PUBLIC, "", "()V", null, null) + methodVisitor.visitCode() + methodVisitor.visitVarInsn(ALOAD, 0) + methodVisitor.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "", "()V", false) + methodVisitor.visitInsn(RETURN) + methodVisitor.visitMaxs(1, 1) + methodVisitor.visitEnd() + } + run { + methodVisitor = classWriter.visitMethod(ACC_PUBLIC or ACC_FINAL, "serve", "()V", null, null) + methodVisitor.visitCode() + methodVisitor.visitTypeInsn(NEW, "com/intellij/json/JsonParserDefinition") + methodVisitor.visitInsn(DUP) + methodVisitor.visitMethodInsn(INVOKESPECIAL, "com/intellij/json/JsonParserDefinition", "", "()V", false) + methodVisitor.visitInsn(POP) + methodVisitor.visitInsn(RETURN) + methodVisitor.visitMaxs(2, 1) + methodVisitor.visitEnd() + } + classWriter.visitEnd() + + return classWriter.toByteArray() + } + + /** + * A `com.intellij.tasks.TaskRepositorySubtype` dump as of IU-242.21713. + * + */ + fun ComIntellijTasks_TaskRepositorySubtype(): ByteArray = ClassWriter(0).apply { + visit( + V17, + ACC_PUBLIC or ACC_ABSTRACT or ACC_INTERFACE, + "com/intellij/tasks/TaskRepositorySubtype", + null, + "java/lang/Object", + null + ) + + visitMethod(ACC_PUBLIC or ACC_ABSTRACT, "getName", "()Ljava/lang/String;", null, null).apply { + visitEnd() + } + visitMethod(ACC_PUBLIC or ACC_ABSTRACT, "getIcon", "()Ljavax/swing/Icon;", null, null).apply { + visitEnd() + } + + visitMethod( + ACC_PUBLIC or ACC_ABSTRACT, "createRepository", "()Lcom/intellij/tasks/TaskRepository;", null, null + ).apply { + visitEnd() + } + visitEnd() + }.toByteArray() } diff --git a/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/IdePlugins.kt b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/IdePlugins.kt new file mode 100644 index 0000000000..04e09e09e3 --- /dev/null +++ b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/IdePlugins.kt @@ -0,0 +1,45 @@ +package com.jetbrains.pluginverifier.tests.mocks + +import com.jetbrains.plugin.structure.intellij.classes.plugin.IdePluginClassesLocations +import com.jetbrains.plugin.structure.intellij.plugin.IdePlugin +import com.jetbrains.plugin.structure.intellij.version.IdeVersion +import com.jetbrains.pluginverifier.plugin.PluginDetails +import com.jetbrains.pluginverifier.repository.PluginInfo +import com.jetbrains.pluginverifier.repository.files.FileLock +import com.jetbrains.pluginverifier.repository.files.IdleFileLock +import com.jetbrains.pluginverifier.repository.repositories.bundled.BundledPluginInfo +import org.objectweb.asm.tree.ClassNode +import java.io.Closeable +import java.nio.file.Files +import java.nio.file.Path + +internal val IdePlugin.emptyClassesLocations + get() = IdePluginClassesLocations(this, allocatedResource = Closeable {}, locations = emptyMap()) + +internal fun IdePlugin.emptyLock(lockPath: Path): FileLock = IdleFileLock(lockPath) + +internal fun IdePlugin.emptyLock(): FileLock = emptyLock(Files.createTempFile("ide-plugin", ".lock")) + +internal val IdePlugin.pluginInfo + get() = createMockPluginInfo(pluginId!!, pluginVersion!!) + +internal fun IdePlugin.bundledPluginInfo(ideVersion: IdeVersion): PluginInfo = BundledPluginInfo(ideVersion, this) + +internal fun IdePlugin.getDetails(classesLocations: IdePluginClassesLocations = emptyClassesLocations): PluginDetails = + PluginDetails(pluginInfo, idePlugin = this, pluginWarnings = emptyList(), classesLocations, emptyLock()) + +internal fun IdePlugin.getDetails(pluginInfo: PluginInfo): PluginDetails = + PluginDetails(pluginInfo, idePlugin = this, pluginWarnings = emptyList(), emptyClassesLocations, emptyLock()) + +internal fun bundledPluginClassesLocation(plugin: IdePlugin, classNodes: List): IdePluginClassesLocations { + val pluginIdString = plugin.pluginId ?: "unknown plugin ID" + val classesLocator = MockClassesLocator(pluginIdString, classNodes) + val locationKey = MockLocationKey(pluginIdString, classesLocator) + + return IdePluginClassesLocations( + plugin, + allocatedResource = Closeable {}, + locations = mapOf(locationKey to classesLocator.getClassResolvers()) + ) +} + diff --git a/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/MockClassesLocator.kt b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/MockClassesLocator.kt new file mode 100644 index 0000000000..7c549c90ad --- /dev/null +++ b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/MockClassesLocator.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2000-2024 JetBrains s.r.o. and other contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package com.jetbrains.pluginverifier.tests.mocks + +import com.jetbrains.plugin.structure.classes.resolvers.FileOrigin +import com.jetbrains.plugin.structure.classes.resolvers.FixedClassesResolver +import com.jetbrains.plugin.structure.classes.resolvers.Resolver +import com.jetbrains.plugin.structure.intellij.classes.locator.ClassesLocator +import com.jetbrains.plugin.structure.intellij.classes.locator.LocationKey +import com.jetbrains.plugin.structure.intellij.plugin.IdePlugin +import org.objectweb.asm.tree.ClassNode +import java.nio.file.Path + +class MockClassesLocator(private val name: String, private val classes: List) : ClassesLocator { + + override val locationKey: LocationKey + get() = MockLocationKey(name, this) + + private val origin = object : FileOrigin { + override val parent = null + } + + override fun findClasses(idePlugin: IdePlugin, pluginFile: Path) = getClassResolvers() + + fun getClassResolvers(): List = listOf(FixedClassesResolver.create(classes, origin)) +} \ No newline at end of file diff --git a/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/MockDependencyFinder.kt b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/MockDependencyFinder.kt new file mode 100644 index 0000000000..e834219014 --- /dev/null +++ b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/MockDependencyFinder.kt @@ -0,0 +1,10 @@ +package com.jetbrains.pluginverifier.tests.mocks + +import com.jetbrains.pluginverifier.dependencies.resolution.DependencyFinder + +class MockDependencyFinder : DependencyFinder { + override val presentableName: String = "Mock Dependency Finder" + + override fun findPluginDependency(dependencyId: String, isModule: Boolean) = + DependencyFinder.Result.NotFound("Mock Dependency Finder does not support any dependencies") +} \ No newline at end of file diff --git a/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/MockLocationKey.kt b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/MockLocationKey.kt new file mode 100644 index 0000000000..863d0ff8ba --- /dev/null +++ b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/MockLocationKey.kt @@ -0,0 +1,9 @@ +package com.jetbrains.pluginverifier.tests.mocks + +import com.jetbrains.plugin.structure.classes.resolvers.Resolver +import com.jetbrains.plugin.structure.intellij.classes.locator.ClassesLocator +import com.jetbrains.plugin.structure.intellij.classes.locator.LocationKey + +class MockLocationKey(override val name: String, val classesLocator: ClassesLocator) : LocationKey { + override fun getLocator(readMode: Resolver.ReadMode) = classesLocator +} \ No newline at end of file diff --git a/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/MockPackageFilter.kt b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/MockPackageFilter.kt new file mode 100644 index 0000000000..cb2da3b95f --- /dev/null +++ b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/MockPackageFilter.kt @@ -0,0 +1,7 @@ +package com.jetbrains.pluginverifier.tests.mocks + +import com.jetbrains.pluginverifier.verifiers.packages.PackageFilter + +class MockPackageFilter : PackageFilter { + override fun acceptPackageOfClass(binaryClassName: String) = true +} \ No newline at end of file diff --git a/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/MockPluginDetailsProviderLock.kt b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/MockPluginDetailsProviderLock.kt new file mode 100644 index 0000000000..0aee556e60 --- /dev/null +++ b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/MockPluginDetailsProviderLock.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2000-2024 JetBrains s.r.o. and other contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package com.jetbrains.pluginverifier.tests.mocks + +import com.jetbrains.plugin.structure.ide.Ide +import com.jetbrains.plugin.structure.intellij.classes.plugin.IdePluginClassesLocations +import com.jetbrains.plugin.structure.intellij.plugin.IdePlugin +import com.jetbrains.pluginverifier.plugin.PluginDetails +import com.jetbrains.pluginverifier.plugin.PluginDetailsProvider +import com.jetbrains.pluginverifier.repository.PluginInfo +import com.jetbrains.pluginverifier.repository.WithIdePlugin +import com.jetbrains.pluginverifier.repository.cleanup.SizeWeight +import com.jetbrains.pluginverifier.repository.resources.ResourceInfo +import com.jetbrains.pluginverifier.repository.resources.ResourceLock +import java.time.Instant + +class MockPluginDetailsProviderLock private constructor( + resourceInfo: ResourceInfo +) : + ResourceLock( + lockTime = Instant.now(), + resourceInfo = resourceInfo + ) { + + override fun release() = Unit + + companion object { + fun of(pluginInfo: PluginInfo, pluginDetailsProvider: PluginDetailsProvider): MockPluginDetailsProviderLock? { + if (pluginInfo is WithIdePlugin) { + val details = pluginDetailsProvider.providePluginDetails(pluginInfo, pluginInfo.idePlugin) + if (details is PluginDetailsProvider.Result.Provided) { + val resource = ResourceInfo(details, SizeWeight(1)) + return MockPluginDetailsProviderLock(resource) + } + } + return null + } + + fun of(plugin: IdePlugin): MockPluginDetailsProviderLock? { + return of(plugin.getDetails()) + } + + fun of(plugin: IdePlugin, pluginClassesLocation: IdePluginClassesLocations): MockPluginDetailsProviderLock? { + val details = plugin.getDetails(pluginClassesLocation) + return of(details) + } + + fun ofBundledPlugin(plugin: IdePlugin, ide: Ide): MockPluginDetailsProviderLock? { + val info = plugin.bundledPluginInfo(ide.version) + val details = plugin.getDetails(info) + return of(details) + } + + private fun of(details: PluginDetails): MockPluginDetailsProviderLock { + val providedDetails = PluginDetailsProvider.Result.Provided(details) + val resource = ResourceInfo(providedDetails, SizeWeight(1)) + return MockPluginDetailsProviderLock(resource) + } + } +} diff --git a/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/MockSinglePluginDetailsCache.kt b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/MockSinglePluginDetailsCache.kt new file mode 100644 index 0000000000..5e2f3ba3de --- /dev/null +++ b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/MockSinglePluginDetailsCache.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2000-2024 JetBrains s.r.o. and other contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package com.jetbrains.pluginverifier.tests.mocks + +import com.jetbrains.pluginverifier.plugin.PluginDetailsCache +import com.jetbrains.pluginverifier.plugin.PluginDetailsProvider +import com.jetbrains.pluginverifier.repository.PluginInfo +import com.jetbrains.pluginverifier.repository.cache.ResourceCacheEntry + +class MockSinglePluginDetailsCache( + private val supportedPluginId: String, + private val pluginDetailsProvider: PluginDetailsProvider +) : PluginDetailsCache { + override fun getPluginDetailsCacheEntry(pluginInfo: PluginInfo): PluginDetailsCache.Result { + return if (supportedPluginId != pluginInfo.pluginId) { + fail(pluginInfo) + } else { + MockPluginDetailsProviderLock.of(pluginInfo, pluginDetailsProvider)?.let { lock -> + PluginDetailsCache.Result.Provided(ResourceCacheEntry(lock)) + } ?: fail(pluginInfo) + } + } + + private fun fail(pluginInfo: PluginInfo): PluginDetailsCache.Result.Failed { + val msg = "Unsupported plugin: ${pluginInfo.pluginId}" + return PluginDetailsCache.Result.Failed(msg, IllegalStateException(msg)) + } + + override fun close() = Unit +} \ No newline at end of file diff --git a/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/PluginBuilders.kt b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/PluginBuilders.kt index 5ebc0bef00..9edcf5b359 100644 --- a/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/PluginBuilders.kt +++ b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/PluginBuilders.kt @@ -2,7 +2,9 @@ package com.jetbrains.pluginverifier.tests.mocks import com.jetbrains.plugin.structure.base.plugin.PluginCreationSuccess import com.jetbrains.plugin.structure.base.utils.contentBuilder.ContentBuilder +import com.jetbrains.plugin.structure.base.utils.contentBuilder.ContentSpec import com.jetbrains.plugin.structure.base.utils.contentBuilder.buildDirectory +import com.jetbrains.plugin.structure.base.utils.contentBuilder.buildDirectoryContent import com.jetbrains.plugin.structure.base.utils.contentBuilder.buildZipFile import com.jetbrains.plugin.structure.ide.Ide import com.jetbrains.plugin.structure.ide.IdeManager @@ -11,7 +13,7 @@ import com.jetbrains.plugin.structure.intellij.plugin.IdePluginManager import org.junit.Assert.assertEquals import java.nio.file.Path -internal data class IdeaPluginSpec(val id: String, val vendor: String) +internal data class IdeaPluginSpec(val id: String, val vendor: String, val dependencies: List = emptyList()) internal fun Path.buildIdePlugin(pluginContentBuilder: (ContentBuilder).() -> Unit): IdePlugin { val pluginFile = buildZipFile(zipFile = this) { @@ -52,4 +54,63 @@ internal fun Path.buildCoreIde(): Ide { val ide = IdeManager.createManager().createIde(ideaDirectory) assertEquals("IU-192.1", ide.version.asString()) return ide +} + +internal fun bundledPlugin(s: PluginSpec.() -> Unit): PluginSpec { + val builder = PluginSpec() + s(builder) + return builder +} + +internal class PluginSpec { + var id: String = "simple-plugin" + + /** + * Plugin artifact name. Mapped to directory name or JAR name (without `.jar` suffix). + */ + var artifactName: String? = null + + var descriptorContent: String = """ + + $id + + + """.trimIndent() + + var classContentBuilder: (ContentBuilder.() -> Unit)? = null + + private val dirName: String + get() = artifactName?.let { return it } ?: id + + private val jarName: String + get() = "$dirName.jar" + + fun build(): ContentSpec = buildDirectoryContent { + dir(dirName) { + dir("lib") { + zip("${artifactName}.jar") { + dir("META-INF") { + file("plugin.xml", descriptorContent) + } + } + } + } + } + + fun build(contentBuilder: ContentBuilder) = with(contentBuilder) { + dir(dirName) { + dir("lib") { + buildJar(this) + } + } + } + + internal fun buildJar(contentBuilder: ContentBuilder) = with(contentBuilder) { + zip(jarName) { + dir("META-INF") { + file("plugin.xml", descriptorContent) + } + classContentBuilder?.invoke(this) + } + } } \ No newline at end of file diff --git a/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/PluginDescriptors.kt b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/PluginDescriptors.kt index 911071061b..eb81049247 100644 --- a/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/PluginDescriptors.kt +++ b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/PluginDescriptors.kt @@ -9,7 +9,8 @@ internal fun ideaPlugin(pluginId: String = "someid", vendor: String = "vendor", sinceBuild: String = "131.1", untilBuild: String = "231.1", - description: String = "this description is looooooooooong enough") = """ + description: String = "this description is looooooooooong enough", + additionalDepends: String = "") = """ $pluginId $pluginName someVersion @@ -18,8 +19,11 @@ internal fun ideaPlugin(pluginId: String = "someid", these change-notes are looooooooooong enough com.intellij.modules.platform + $additionalDepends """ +internal fun String.withRootElement(): String = "$this" + internal fun ContentBuilder.descriptor(header: String) { dir("META-INF") { file("plugin.xml") { diff --git a/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/RuleBasedDependencyFinder.kt b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/RuleBasedDependencyFinder.kt new file mode 100644 index 0000000000..8e41995254 --- /dev/null +++ b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/RuleBasedDependencyFinder.kt @@ -0,0 +1,55 @@ +package com.jetbrains.pluginverifier.tests.mocks + +import com.jetbrains.plugin.structure.ide.Ide +import com.jetbrains.plugin.structure.intellij.classes.plugin.IdePluginClassesLocations +import com.jetbrains.plugin.structure.intellij.plugin.IdePlugin +import com.jetbrains.pluginverifier.dependencies.resolution.DependencyFinder +import com.jetbrains.pluginverifier.dependencies.resolution.DependencyFinder.Result.NotFound +import com.jetbrains.pluginverifier.plugin.PluginDetailsCache +import com.jetbrains.pluginverifier.repository.cache.ResourceCacheEntry +import org.objectweb.asm.tree.ClassNode + +class RuleBasedDependencyFinder(private val ide: Ide, private val rules: List): DependencyFinder { + + override val presentableName: String = "Rule-based dependency finder with ${rules.size} rules" + + override fun findPluginDependency(dependencyId: String, isModule: Boolean): DependencyFinder.Result { + return findRule(dependencyId) + ?.toDependencyResolution() + ?: NotFound("Dependency $dependencyId is not found by $presentableName") + } + + private fun findRule(dependencyId: String): Rule? = + rules.find { it.dependencyId == dependencyId } + + private fun Rule.toDependencyResolution(): DependencyFinder.Result { + return createLock() + ?.let { lock -> ResourceCacheEntry(lock) } + ?.let { cacheEntry -> PluginDetailsCache.Result.Provided(cacheEntry) } + ?.let { cacheResult -> DependencyFinder.Result.DetailsProvided(cacheResult) } + ?: NotFound("Dependency ${plugin.pluginId} is not found by $presentableName") + } + + private fun Rule.createLock(): MockPluginDetailsProviderLock? { + return if (isBundledPlugin) { + MockPluginDetailsProviderLock.ofBundledPlugin(plugin, ide) + } else { + MockPluginDetailsProviderLock.of(plugin, toClassesLocations()) + } + } + + private fun Rule.toClassesLocations(): IdePluginClassesLocations { + return if (classNodes.isEmpty()) { + plugin.emptyClassesLocations + } else { + bundledPluginClassesLocation(plugin, classNodes) + } + } + + companion object { + fun create(ide: Ide, vararg rules: Rule): DependencyFinder = RuleBasedDependencyFinder(ide, rules.toList()) + fun create(ide: Ide, rules: List): DependencyFinder = RuleBasedDependencyFinder(ide, rules) + } + + data class Rule(val dependencyId: String, val plugin: IdePlugin, val classNodes: List = emptyList(), val isBundledPlugin: Boolean = false) +} \ No newline at end of file diff --git a/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/asm/Classes.kt b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/asm/Classes.kt new file mode 100644 index 0000000000..e75495fb44 --- /dev/null +++ b/intellij-plugin-verifier/verifier-test/src/test/java/com/jetbrains/pluginverifier/tests/mocks/asm/Classes.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2000-2020 JetBrains s.r.o. and other contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package com.jetbrains.pluginverifier.tests.mocks.asm + +import com.jetbrains.pluginverifier.verifiers.resolution.BinaryClassName +import org.objectweb.asm.Opcodes.* +import org.objectweb.asm.tree.ClassNode + +/** + * Creates an empty class with the following features: + * + * * inherits from `java.lang.Object`, + * * has a single no-argument constructor (default constructor) with an empty body. + */ +fun publicClass(className: BinaryClassName): ClassNode { + return ClassNode(ASM9).apply { + name = className + superName = "java/lang/Object" + access = ACC_PUBLIC or ACC_SUPER + methods.add(constructorPublicNoArg()) + } +} \ No newline at end of file