diff --git a/platform/build-scripts/product-dsl/src/generator/PluginXmlDependencyGenerator.kt b/platform/build-scripts/product-dsl/src/generator/PluginXmlDependencyGenerator.kt index ab3f0ca76b0a..ad73b415fe09 100644 --- a/platform/build-scripts/product-dsl/src/generator/PluginXmlDependencyGenerator.kt +++ b/platform/build-scripts/product-dsl/src/generator/PluginXmlDependencyGenerator.kt @@ -30,6 +30,8 @@ import org.jetbrains.intellij.build.productLayout.pipeline.Slots import org.jetbrains.intellij.build.productLayout.stats.SuppressionType import org.jetbrains.intellij.build.productLayout.stats.SuppressionUsage import org.jetbrains.intellij.build.productLayout.xml.LegacyMigrationResult +import org.jetbrains.intellij.build.productLayout.xml.extractDependenciesEntries +import org.jetbrains.intellij.build.productLayout.xml.removeDuplicateLegacyDepends /** * Planner for plugin.xml dependency XML files. @@ -215,12 +217,29 @@ private suspend fun buildPluginDependencyPlan( val deps = filterPluginDependencies(graphDeps, info, effectiveFilter, suppressionConfig, updateSuppressions) - // Legacy entries are NOT migrated - they stay as-is - // Generator only manages section, doesn't touch legacy format - val legacyMigration = LegacyMigrationResult(content = info.pluginXmlContent, pluginDepsToAdd = emptyList()) + // Remove duplicate legacy only when modern deps are present or we are generating a section. + val hasDependenciesSection = extractDependenciesEntries(info.pluginXmlContent) != null + val hasModernDepsInXIncludes = info.depsByFile.drop(1).any { it.pluginDependencies.isNotEmpty() || it.moduleDependencies.isNotEmpty() } + val legacyPluginIds = info.legacyDepends.map { it.pluginId.value }.sorted() + val autoPluginIds = deps.pluginDependencies.map { it.value }.sorted() + val shouldRemoveLegacyDuplicates = hasDependenciesSection || hasModernDepsInXIncludes || deps.moduleDependencies.isNotEmpty() || autoPluginIds != legacyPluginIds - // Merge legacy plugin deps with auto-generated plugin deps (convert to String for XML processing) - val allPluginDepsStrings = (deps.pluginDependencies.map { it.value } + legacyMigration.pluginDepsToAdd).distinct().sorted() + val modernPluginIds = HashSet().apply { + addAll(deps.pluginDependencies) + for (fileDeps in info.depsByFile) { + addAll(fileDeps.pluginDependencies) + } + } + + val legacyMigration = if (shouldRemoveLegacyDuplicates) { + removeDuplicateLegacyDepends(info.pluginXmlContent, modernPluginIds) + } + else { + LegacyMigrationResult(content = info.pluginXmlContent) + } + + // Merge auto-generated plugin deps (convert to String for XML processing) + val allPluginDepsStrings = deps.pluginDependencies.map { it.value }.distinct().sorted() // Compute xi:include deps from depsByFile (first entry = main file, rest = xi:includes) // These are deps already present in xi:included files, so we don't need to add them to the main file @@ -240,6 +259,7 @@ private suspend fun buildPluginDependencyPlan( } val preserveExistingPluginDeps = existingXmlPluginDeps.filterTo(LinkedHashSet()) { it.value !in allPluginDepsStrings } + val effectiveLegacyDepends = info.legacyDepends.filterNot { it.pluginId in legacyMigration.removedLegacyPluginIds } return PluginDependencyPlan( pluginContentModuleName = pluginContentModuleName, @@ -247,7 +267,7 @@ private suspend fun buildPluginDependencyPlan( pluginXmlContent = legacyMigration.content, moduleDependencies = deps.moduleDependencies.distinctBy { it.value }.sortedBy { it.value }, pluginDependencies = deps.pluginDependencies.distinctBy { it.value }.sortedBy { it.value }, - legacyPluginDependencies = info.legacyDepends.map { it.pluginId }, + legacyPluginDependencies = effectiveLegacyDepends.map { it.pluginId }, xiIncludeModuleDeps = xiIncludeModuleDeps, xiIncludePluginDeps = xiIncludePluginDeps, existingXmlModuleDependencies = existingXmlModuleDeps, diff --git a/platform/build-scripts/product-dsl/src/xml/XmlDependencyUpdater.kt b/platform/build-scripts/product-dsl/src/xml/XmlDependencyUpdater.kt index 70bb1ee1109f..24a5a0c58dec 100644 --- a/platform/build-scripts/product-dsl/src/xml/XmlDependencyUpdater.kt +++ b/platform/build-scripts/product-dsl/src/xml/XmlDependencyUpdater.kt @@ -23,6 +23,11 @@ private const val LEGACY_MARKER = "editor-fold desc=\"Generated dependencies" private enum class RegionType { NONE, WRAPS_ENTIRE_SECTION, INSIDE_SECTION } +private data class RemovalRange( + @JvmField val start: Int, + @JvmField val end: Int, +) + private sealed class DepEntry { data class Plugin(val id: String) : DepEntry() data class Module(val name: String) : DepEntry() @@ -300,8 +305,82 @@ private fun insertDependenciesSection(content: String, modules: List, pl val nextLine = content.indexOf('\n', pos + 1) val indent = (if (nextLine != -1) content.substring(nextLine + 1).takeWhile { it == ' ' || it == '\t' } else "").ifEmpty { " " } - val suffix = content.substring(pos + 1).let { if (it.startsWith('\n')) it.substring(1) else it } - return content.substring(0, pos + 1) + "\n" + buildFullBlock(indent, emptyList(), modules, plugins) + suffix + val insertPos = findDependenciesInsertPos(content, pos + 1) + val prefix = content.substring(0, insertPos) + val suffix = content.substring(insertPos) + val normalizedPrefix = if (prefix.isNotEmpty() && prefix.last() != '\n') "$prefix\n" else prefix + return normalizedPrefix + buildFullBlock(indent, emptyList(), modules, plugins) + suffix +} + +private val METADATA_TAGS = setOf( + "id", + "name", + "description", + "category", + "vendor", + "version", + "idea-version", + "change-notes", +) + +private fun findDependenciesInsertPos(content: String, rootContentStart: Int): Int { + val metadataEnd = findMetadataEndOffset(content) ?: return rootContentStart + var pos = content.indexOf('\n', metadataEnd).let { if (it == -1) content.length else it + 1 } + while (pos < content.length) { + val lineEnd = content.indexOf('\n', pos).let { if (it == -1) content.length else it + 1 } + if (content.substring(pos, lineEnd).trim().isNotEmpty()) break + pos = lineEnd + } + return pos +} + +private fun findMetadataEndOffset(content: String): Int? { + val reader = createXmlStreamReaderWithLocation(StringReader(content)) + try { + var depth = 0 + var lastEnd = -1 + while (reader.hasNext()) { + when (reader.next()) { + XMLStreamConstants.START_ELEMENT -> { + val localName = reader.localName + if (depth == 0 && localName == "idea-plugin") { + depth = 1 + continue + } + if (depth > 0) { + val currentDepth = depth + depth++ + if (currentDepth == 1 && METADATA_TAGS.contains(localName)) { + val startOffset = content.lastIndexOf("<$localName", reader.location.characterOffset) + if (startOffset != -1) { + val endOffset = findElementEndOffset(content, localName, startOffset) + if (endOffset != null && endOffset > lastEnd) { + lastEnd = endOffset + } + } + } + } + } + XMLStreamConstants.END_ELEMENT -> if (depth > 0) depth-- + } + } + return if (lastEnd == -1) null else lastEnd + } + finally { + reader.close() + } +} + +private fun findElementEndOffset(content: String, tag: String, startOffset: Int): Int? { + val startTagEnd = content.indexOf('>', startOffset) + if (startTagEnd == -1) return null + var i = startTagEnd - 1 + while (i >= startOffset && content[i].isWhitespace()) i-- + val selfClosing = i >= startOffset && content[i] == '/' + if (selfClosing) return startTagEnd + 1 + val closeTag = "" + val closeIndex = content.indexOf(closeTag, startTagEnd + 1) + return if (closeIndex == -1) null else closeIndex + closeTag.length } /** Region only with auto-generated modules/plugins (for INSIDE_SECTION - replacing fold content) */ @@ -355,8 +434,72 @@ private fun buildWithEntries(indent: String, manualEntries: List, auto * Result of migrating legacy `` entries to `` format. */ internal data class LegacyMigrationResult( - /** XML content with `` entries removed */ + /** XML content with duplicate legacy `` entries removed */ @JvmField val content: String, - /** Plugin IDs to add as `` dependencies */ - @JvmField val pluginDepsToAdd: List, + /** Legacy plugin IDs removed from the content */ + @JvmField val removedLegacyPluginIds: Set = emptySet(), ) + +/** + * Removes legacy `` entries that duplicate modern `` declarations. + * Only non-optional entries without config-file are removed. + */ +internal fun removeDuplicateLegacyDepends(content: String, modernPluginIds: Set): LegacyMigrationResult { + if (modernPluginIds.isEmpty() || !content.contains("() + val removed = LinkedHashSet() + + val reader = createXmlStreamReaderWithLocation(StringReader(content)) + try { + while (reader.hasNext()) { + when (reader.next()) { + XMLStreamConstants.START_ELEMENT -> if (reader.localName == "depends") { + val optionalAttr = reader.getAttributeValue(null, "optional") + val configFileAttr = reader.getAttributeValue(null, "config-file") + val isOptional = optionalAttr?.equals("true", ignoreCase = true) == true + val hasConfigFile = !configFileAttr.isNullOrEmpty() + val startOffset = content.lastIndexOf("", startOffset) + if (closeIndex != -1) { + var end = closeIndex + "".length + if (end < content.length && content[end] == '\r') end++ + if (end < content.length && content[end] == '\n') end++ + ranges.add(RemovalRange(start, end)) + removed.add(PluginId(pluginId)) + } + } + } + } + } + } + finally { + reader.close() + } + + if (ranges.isEmpty()) { + return LegacyMigrationResult(content = content) + } + + ranges.sortBy { it.start } + val result = StringBuilder(content.length) + var cursor = 0 + for (range in ranges) { + if (range.start < cursor) continue + result.append(content, cursor, range.start) + cursor = range.end + } + result.append(content, cursor, content.length) + return LegacyMigrationResult(content = result.toString(), removedLegacyPluginIds = removed) +} diff --git a/platform/build-scripts/product-dsl/testSrc/xml/XmlDependencyUpdaterTest.kt b/platform/build-scripts/product-dsl/testSrc/xml/XmlDependencyUpdaterTest.kt new file mode 100644 index 000000000000..d929471594f5 --- /dev/null +++ b/platform/build-scripts/product-dsl/testSrc/xml/XmlDependencyUpdaterTest.kt @@ -0,0 +1,61 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.intellij.build.productLayout.xml + +import com.intellij.platform.pluginGraph.PluginId +import org.assertj.core.api.Assertions.assertThat +import org.jetbrains.intellij.build.productLayout.TestFailureLogger +import org.jetbrains.intellij.build.productLayout.util.DeferredFileUpdater +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +@ExtendWith(TestFailureLogger::class) +class XmlDependencyUpdaterTest { + @Test + fun `inserts dependencies after metadata`(@TempDir tempDir: Path) { + val content = """ + + Test Plugin + test.plugin + Test description + + + + + """.trimIndent() + + val updater = DeferredFileUpdater(tempDir) + val path = tempDir.resolve("META-INF/plugin.xml") + updateXmlDependencies( + path = path, + content = content, + moduleDependencies = emptyList(), + pluginDependencies = listOf("dep.plugin"), + strategy = updater, + ) + + val diffs = updater.getDiffs() + assertThat(diffs).hasSize(1) + val xml = diffs.single().expectedContent + assertThat(xml.indexOf("")).isGreaterThan(xml.indexOf("")) + } + + @Test + fun `removes duplicate legacy depends for modern plugin deps`() { + val content = """ + + Test + test + XPathView + XPathView + com.intellij.modules.xml + + """.trimIndent() + + val migrated = removeDuplicateLegacyDepends(content, setOf(PluginId("XPathView"))) + assertThat(migrated.content).doesNotContain("XPathView") + assertThat(migrated.content).contains("XPathView") + assertThat(migrated.content).contains("com.intellij.modules.xml") + } +} diff --git a/plugins/xslt-debugger/resources/META-INF/plugin.xml b/plugins/xslt-debugger/resources/META-INF/plugin.xml index fd1edd008941..633980d4a325 100644 --- a/plugins/xslt-debugger/resources/META-INF/plugin.xml +++ b/plugins/xslt-debugger/resources/META-INF/plugin.xml @@ -9,8 +9,11 @@ JetBrains - XPathView - com.intellij.modules.xml + + + + +