From d01a7f791b72094d54c2896620ce875e992c3cd6 Mon Sep 17 00:00:00 2001 From: teeckoo Date: Sun, 31 May 2026 07:36:30 +0530 Subject: [PATCH 1/2] Fix crash on IntelliJ import with dependsOn(RootProject(...)) (#146) --- .../SbtProjectStructureExtractorBase.scala | 54 +++++- ...ctStructureExtractorExternalRefsTest.scala | 160 ++++++++++++++++++ .../scala/org/jetbrains/sbtidea/Init.scala | 8 +- 3 files changed, 212 insertions(+), 10 deletions(-) create mode 100644 core/src/test/scala/org/jetbrains/sbtidea/structure/sbtImpl/SbtProjectStructureExtractorExternalRefsTest.scala diff --git a/core/src/main/scala/org/jetbrains/sbtidea/structure/sbtImpl/SbtProjectStructureExtractorBase.scala b/core/src/main/scala/org/jetbrains/sbtidea/structure/sbtImpl/SbtProjectStructureExtractorBase.scala index 08c61ce4..feb9926a 100644 --- a/core/src/main/scala/org/jetbrains/sbtidea/structure/sbtImpl/SbtProjectStructureExtractorBase.scala +++ b/core/src/main/scala/org/jetbrains/sbtidea/structure/sbtImpl/SbtProjectStructureExtractorBase.scala @@ -16,20 +16,53 @@ trait SbtProjectStructureExtractorBase extends ProjectStructureExtractor { val buildDependencies: BuildDependencies val projectsData: Seq[ProjectDataType] + /** + * Maps each [[ProjectRef]] of the current build to its extracted project data. + * + * Only projects that belong to this build (i.e. present in `projectsData`) are included. + * Projects pulled in via `dependsOn(RootProject(...))` live in their own builds and don't + * run sbt-idea-plugin, so we have no data for them and they are deliberately absent here. + * That is why every lookup against `projectMap` in this trait is guarded with + * `projectMap.contains` / `projectMap.get`: an unguarded `projectMap(externalRef)` is exactly + * the `NoSuchElementException` reported in issue #146. + * + * The filtering behaviour is covered by `SbtProjectStructureExtractorExternalRefsTest`. + */ protected lazy val projectMap: Map[ProjectRef, ProjectDataType] = projectsData.iterator.map(x => x.thisProject -> x).toMap - protected lazy val revProjectMap: Seq[(ProjectRef, ProjectRef)] = projectsData.flatMap(x => buildDependencies.classpathRefs(x.thisProject).map(_ -> x.thisProject)) + // `filter(projectMap.contains)` drops reverse edges to external projects (dependsOn(RootProject(...))): + // they are not in projectMap, so leaving them in would later crash collectParents' `projectMap(ref)` + // lookup (the original #146 bug). See SbtProjectStructureExtractorExternalRefsTest. + protected lazy val revProjectMap: Seq[(ProjectRef, ProjectRef)] = projectsData.flatMap(x => buildDependencies.classpathRefs(x.thisProject).filter(projectMap.contains).map(_ -> x.thisProject)) protected lazy val projectCache: mutable.Map[ProjectRef, NodeType] = mutable.HashMap.empty def findProjectRef(project: Project): Option[ProjectRef] = projectMap.find(_._1.project == project.id).map(_._1) protected def topoSortRefs(root: ProjectRef, queue: Seq[ProjectRef] = Seq.empty): Seq[ProjectRef] = { - val data = projectMap(root) - if (!queue.contains(root)) { - val newQueue = queue :+ root - val direct = buildDependencies.classpathRefs(root).foldLeft(newQueue) { case (q, r) => topoSortRefs(r, q) } - val additional = collectAdditionalProjects(data, direct) - additional - } else { queue } + projectMap.get(root) match { + case None => + // `root` is an external project (dependsOn(RootProject(...))) that isn't part of this + // build, so we have no data for it and can't place it in the graph — skip it. See #146. + log.warn(s"skipping external project ref not part of the current build: $root") + queue + case Some(data) => + if (queue.contains(root)) queue + else enqueueWithDependencies(data, root, queue) + } + } + + /** + * Appends `root` to the topo-sort `queue`, then recursively visits its classpath + * dependencies and any additional projects contributed by subclasses. + * + * External classpath refs are filtered out here for the same reason as in [[projectMap]]: + * they belong to other builds and have no entry to recurse into. + */ + private def enqueueWithDependencies(data: ProjectDataType, root: ProjectRef, queue: Seq[ProjectRef]): Seq[ProjectRef] = { + val newQueue = queue :+ root + val direct = buildDependencies.classpathRefs(root) + .filter(projectMap.contains) + .foldLeft(newQueue) { case (q, r) => topoSortRefs(r, q) } + collectAdditionalProjects(data, direct) } protected def collectAdditionalProjects(data: ProjectDataType, direct: Seq[ProjectRef]): Seq[ProjectRef] = direct @@ -69,7 +102,10 @@ trait SbtProjectStructureExtractorBase extends ProjectStructureExtractor { } override def collectChildren(node: NodeType, data: ProjectDataType): Seq[NodeType] = { - val childRefs = buildDependencies.classpathRefs(node.ref) + // Same rationale as revProjectMap/topoSortRefs: external classpath refs + // (dependsOn(RootProject(...))) have no cached stub, so exclude them before lookup. See #146 + // and SbtProjectStructureExtractorExternalRefsTest. + val childRefs = buildDependencies.classpathRefs(node.ref).filter(projectCache.contains) assert(childRefs.forall(projectCache.contains), s"Child stubs incomplete: ${childRefs.filterNot(projectCache.contains)}") childRefs.map(projectCache) } diff --git a/core/src/test/scala/org/jetbrains/sbtidea/structure/sbtImpl/SbtProjectStructureExtractorExternalRefsTest.scala b/core/src/test/scala/org/jetbrains/sbtidea/structure/sbtImpl/SbtProjectStructureExtractorExternalRefsTest.scala new file mode 100644 index 00000000..078c11f7 --- /dev/null +++ b/core/src/test/scala/org/jetbrains/sbtidea/structure/sbtImpl/SbtProjectStructureExtractorExternalRefsTest.scala @@ -0,0 +1,160 @@ +package org.jetbrains.sbtidea.structure.sbtImpl + +import org.jetbrains.sbtidea.PluginLogger +import org.jetbrains.sbtidea.structure._ +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import sbt._ +import sbt.internal.BuildDependencies + +import java.io.File +import java.net.URI + +/** + * Verifies how [[SbtProjectStructureExtractorBase]] handles '''external''' project references — + * those introduced by `dependsOn(RootProject(...))`, which belong to a different sbt build and + * are therefore absent from `projectsData` / `projectMap`. + * + * Regression coverage for issue #146: before the fix an external ref reaching `projectMap(ref)` + * threw a `NoSuchElementException` during IntelliJ import. The cases below exercise the guarded + * lookups in `topoSortRefs`, `revProjectMap` and `collectChildren`. + * + * These scenarios can't be reused from the existing integration-style structure tests: those load + * a real sbt build where every project shares one build, so no ref is ever "external". Here we + * drive the trait directly with a synthetic dependency graph and a hand-built [[BuildDependencies]] + * (via the public `BuildDependencies.apply` factory), which is the only way to introduce a ref + * that is on the classpath yet missing from `projectMap`. + */ +class SbtProjectStructureExtractorExternalRefsTest extends AnyFunSuite with Matchers { + + private def mkRef(name: String, uri: String = "file:///tmp/build/"): ProjectRef = + ProjectRef(new URI(uri), name) + + /** Builds a real [[BuildDependencies]] from a plain `ref -> classpath deps` adjacency map. */ + private def mkBuildDependencies(classpathDeps: Map[ProjectRef, Seq[ProjectRef]]): BuildDependencies = { + val classpath: Map[ProjectRef, Seq[ClasspathDep[ProjectRef]]] = + classpathDeps.map { case (ref, deps) => + ref -> deps.map(dep => ResolvedClasspathDependency(dep, None): ClasspathDep[ProjectRef]) + } + BuildDependencies(classpath, Map.empty) + } + + private case class StubProjectData( + thisProject: ProjectRef, + cp: sbt.Def.Classpath = Nil, + definedDeps: Seq[ModuleID] = Nil, + productDirs: Seq[File] = Nil, + report: UpdateReport = null + ) extends CommonSbtProjectData + + private class StubNode( + val ref: ProjectRef, + val name: String + ) extends SbtProjectNode { + override type T = StubNode + var parents: Seq[StubNode] = Nil + var children: Seq[StubNode] = Nil + var libs: Seq[Library] = Nil + } + + /** + * Concrete extractor over the stub types. It implements only the genuinely abstract members + * (`buildStub`, `updateNode`, `collectLibraries`); all dependency traversal runs against the + * real [[BuildDependencies]] passed in, so the production `topoSortRefs` / `revProjectMap` / + * `collectChildren` logic is exercised verbatim — nothing is re-implemented here. + */ + private class TestExtractor( + override val rootProject: ProjectRef, + override val projectsData: Seq[StubProjectData], + override val buildDependencies: BuildDependencies + ) extends SbtProjectStructureExtractorBase { + override type ProjectDataType = StubProjectData + override type NodeType = StubNode + + override implicit val log: PluginLogger = PluginLogger + + override def buildStub(data: StubProjectData): StubNode = + new StubNode(data.thisProject, data.thisProject.project) + + override def updateNode(node: StubNode, data: StubProjectData): StubNode = { + node.children = collectChildren(node, data) + node.parents = collectParents(node, data) + node.libs = Nil + node + } + + override def collectLibraries(data: StubProjectData): Seq[Library] = Nil + + // expose protected members for testing + def testTopoSortRefs(root: ProjectRef): Seq[ProjectRef] = topoSortRefs(root) + def testRevProjectMap: Seq[(ProjectRef, ProjectRef)] = revProjectMap + } + + test("topoSortRefs skips external ProjectRefs not in projectMap") { + val internalRef = mkRef("internal-project") + val externalRef = mkRef("external-project", "file:///tmp/external-build/") + + val projectsData = Seq(StubProjectData(internalRef)) + val extractor = new TestExtractor(internalRef, projectsData, + mkBuildDependencies(Map(internalRef -> Seq(externalRef))) + ) + val sorted = extractor.testTopoSortRefs(internalRef) + + sorted should contain(internalRef) + sorted should not contain externalRef + } + + test("topoSortRefs returns empty queue when root is external") { + val externalRef = mkRef("external-project", "file:///tmp/external-build/") + + val extractor = new TestExtractor(externalRef, Seq.empty, mkBuildDependencies(Map.empty)) + val sorted = extractor.testTopoSortRefs(externalRef) + + sorted shouldBe empty + } + + test("topoSortRefs works normally with all-internal refs") { + val refA = mkRef("a") + val refB = mkRef("b") + val refC = mkRef("c") + + val projectsData = Seq(StubProjectData(refA), StubProjectData(refB), StubProjectData(refC)) + val extractor = new TestExtractor(refA, projectsData, + mkBuildDependencies(Map(refA -> Seq(refB), refB -> Seq(refC), refC -> Nil)) + ) + val sorted = extractor.testTopoSortRefs(refA) + + sorted should contain allOf(refA, refB, refC) + sorted.size shouldBe 3 + } + + test("revProjectMap filters out external refs") { + val internalA = mkRef("a") + val internalB = mkRef("b") + val externalRef = mkRef("external", "file:///tmp/external/") + + val projectsData = Seq(StubProjectData(internalA), StubProjectData(internalB)) + val extractor = new TestExtractor(internalA, projectsData, + mkBuildDependencies(Map(internalA -> Seq(internalB, externalRef), internalB -> Nil)) + ) + val revMap = extractor.testRevProjectMap + + revMap should contain((internalB, internalA)) + revMap.map(_._1) should not contain externalRef + } + + test("extract succeeds with mixed internal and external dependencies") { + val internalRef = mkRef("plugin") + val externalRef = mkRef("library", "file:///tmp/external/") + + val projectsData = Seq(StubProjectData(internalRef)) + val extractor = new TestExtractor(internalRef, projectsData, + mkBuildDependencies(Map(internalRef -> Seq(externalRef))) + ) + val result = extractor.extract + + result.size shouldBe 1 + result.head.name shouldBe "plugin" + result.head.children shouldBe empty + } +} diff --git a/ideaSupport/src/main/scala/org/jetbrains/sbtidea/Init.scala b/ideaSupport/src/main/scala/org/jetbrains/sbtidea/Init.scala index 0220a8c1..75d37bcb 100644 --- a/ideaSupport/src/main/scala/org/jetbrains/sbtidea/Init.scala +++ b/ideaSupport/src/main/scala/org/jetbrains/sbtidea/Init.scala @@ -23,7 +23,13 @@ trait Init { this: Keys.type => private def isRunningFromIDEA: Boolean = sys.props.contains("idea.managed") lazy val globalSettings : Seq[Setting[?]] = Seq( - intellijAttachSources := true + intellijAttachSources := true, + // Provide defaults for settings that are aggregated across all dependencies + // (including external projects loaded via RootProject/ProjectRef). + // Without these defaults, `ScopeFilter(inDependencies(ThisProject))` fails + // when a dependency project doesn't have sbt-idea-plugin enabled. + intellijPlugins := Seq.empty, + intellijExtraRuntimePluginsInTests := Seq.empty ) lazy val buildSettings: Seq[Setting[?]] = Seq( From acbcd38aefeffa2676dd97a558285daed6622e36 Mon Sep 17 00:00:00 2001 From: teeckoo Date: Sun, 31 May 2026 07:36:43 +0530 Subject: [PATCH 2/2] Include external RootProject dependencies in plugin packaging (#146) packageMappingsOffline now reads (Compile / productDirectories) instead of (Compile / products) for the cross-project collection of external RootProjects. Without this, IntelliJ sync (which calls packageMappingsOffline via createIDEAArtifactXml on every onLoad) force-compiles every project in the build, and a compile error in any external RootProject breaks sync entirely with "extracting project structure from sbt: failed". Online packageMappings continues to use (Compile / products) since real packaging needs compiled class files on disk. This mirrors the existing online/offline split in dumpDependencyStructure(Offline). The external-project data assembly is extracted to PackagingKeysInit.buildExternalProjectData so the plumbing can be unit-tested without an SBT environment; PackagingKeysInitTest covers it. --- README.md | 33 +++ .../sbtidea/packaging/PackagingKeysInit.scala | 96 +++++++- .../mappings/LinearMappingsBuilder.scala | 37 ++- .../ExternalProjectPackagingTest.scala | 211 ++++++++++++++++++ ...nearMappingsBuilderMergeWarningsTest.scala | 38 +--- .../packaging/PackagingKeysInitTest.scala | 142 ++++++++++++ .../packaging/PackagingTestNodes.scala | 75 +++++++ 7 files changed, 587 insertions(+), 45 deletions(-) create mode 100644 packaging/src/test/scala-2.12/org/jetbrains/sbtidea/packaging/ExternalProjectPackagingTest.scala create mode 100644 packaging/src/test/scala-2.12/org/jetbrains/sbtidea/packaging/PackagingKeysInitTest.scala create mode 100644 packaging/src/test/scala-2.12/org/jetbrains/sbtidea/packaging/PackagingTestNodes.scala diff --git a/README.md b/README.md index c6bedbb1..b93df4ab 100644 --- a/README.md +++ b/README.md @@ -512,6 +512,39 @@ If your Scala plugin version is 2024.2.444 or higher, and you enabled separate m then to generate correct mappings and IDEA Run Configuration you should set this property to true (`-Dseparate.prod.test.sources.enabled=true`). Otherwise, there is no need to do anything as this value is set to false by default. +## Source-Level Debugging with `packageArtifactDynamic` + +`packageArtifact` packages all project classes into JARs under `lib/`. This is suitable for distribution but prevents IntelliJ's debugger from mapping classes back to source files in dependent modules. + +For development, use `packageArtifactDynamic` instead: + +``` +sbt packageArtifactDynamic +``` + +This writes class files to a `classes/` directory on disk instead of packaging them into JARs. IntelliJ's debugger can then resolve source files for all modules in the project, enabling **breakpoints across module boundaries** — including external projects loaded via `dependsOn(RootProject(...))`. + +| Task | Output | Source-level debugging | +|------|--------|-----------------------| +| `packageArtifact` | JARs in `lib/` | Only within the main plugin module | +| `packageArtifactDynamic` | Class files in `classes/` | Across all modules including external projects | +| `packageArtifactZip` | Distributable `.zip` | For publishing to JetBrains Marketplace | + +### External Project Support + +`dependsOn(RootProject(...))` works for depending on normal SBT projects that don't use sbt-idea-plugin. External projects' class files are automatically merged into the plugin artifact via `MergeIntoParent()`, and their library dependencies are resolved and included — no `packageFileMappings` or manual `libraryDependencies` pull needed. + +`packageLibraryMappings` set on the root plugin project applies as global defaults to all nodes, including external projects. For example, to exclude scala libraries from the entire artifact (including external project dependencies): + +```scala +packageLibraryMappings ++= Seq( + "org.scala-lang" % "scala.*" % ".*" -> None, + "org.scala-lang.modules" % "scala.*" % ".*" -> None +) +``` + +Node-specific `packageLibraryMappings` override root mappings when both are set. + ## Known Issues and Limitations ### `name` key in projects diff --git a/packaging/src/main/scala/org/jetbrains/sbtidea/packaging/PackagingKeysInit.scala b/packaging/src/main/scala/org/jetbrains/sbtidea/packaging/PackagingKeysInit.scala index cdcdadcf..ea412227 100644 --- a/packaging/src/main/scala/org/jetbrains/sbtidea/packaging/PackagingKeysInit.scala +++ b/packaging/src/main/scala/org/jetbrains/sbtidea/packaging/PackagingKeysInit.scala @@ -8,6 +8,41 @@ import sbt.* import sbt.Def.spaceDelimited import sbt.Keys.* +object PackagingKeysInit { + // Pure assembly of SbtPackageProjectData for external projects (those not already + // covered by pluginData). Extracted from packageMappingsImpl so the data plumbing can + // be unit-tested without an SBT environment — see ExternalProjectPackagingTest. + def buildExternalProjectData( + pluginRefs: Set[ProjectRef], + allRefs: Seq[ProjectRef], + allNames: Seq[String], + allProducts: Seq[Seq[File]], + allClasspaths: Seq[Def.Classpath], + allDefinedDeps: Seq[Seq[ModuleID]], + allReports: Seq[UpdateReport], + ): Seq[SbtPackageProjectData] = + allRefs.indices + .filterNot(i => pluginRefs.contains(allRefs(i))) + .map { i => + SbtPackageProjectData( + thisProject = allRefs(i), + thisProjectName = allNames(i), + cp = allClasspaths(i), + definedDeps = allDefinedDeps(i), + additionalProjects = Seq.empty, + assembleLibraries = false, + productDirs = allProducts(i), + report = allReports(i), + libMapping = Seq.empty, + libraryBaseDir = file("lib"), + additionalMappings = Seq.empty, + packageMethod = PackagingMethod.MergeIntoParent(), + shadePatterns = Seq.empty, + excludeFilter = ExcludeFilter.AllPass + ) + } +} + trait PackagingKeysInit { this: PackagingKeys.type => @@ -41,11 +76,14 @@ trait PackagingKeysInit { packageMappings := { streams.value.log.info("started dumping structure") - packageMappingsImpl(dumpDependencyStructure).value + packageMappingsImpl(dumpDependencyStructure, Compile / products).value }, packageMappingsOffline := { streams.value.log.info("started dumping offline structure") - packageMappingsImpl(dumpDependencyStructureOffline).value + // Offline must use productDirectories (path-only). products would force a compile + // of every project during IntelliJ sync via createIDEAArtifactXml → packageMappingsOffline, + // and a compile error in any external RootProject would then break sync entirely. + packageMappingsImpl(dumpDependencyStructureOffline, Compile / productDirectories).value }, findLibraryMapping := { val args = spaceDelimited("").parsed @@ -100,10 +138,16 @@ trait PackagingKeysInit { } ) - private def packageMappingsImpl(keyFor: TaskKey[SbtPackageProjectData]): Def.Initialize[Task[Mappings]] = Def.task { + private def packageMappingsImpl( + keyFor: TaskKey[SbtPackageProjectData], + productsKey: TaskKey[Seq[File]], + ): Def.Initialize[Task[Mappings]] = Def.task { val rootProject = thisProjectRef.value val buildDeps = buildDependencies.value - val data = keyFor.?.all(ScopeFilter(inAnyProject)).value.flatten.filterNot(_ == null) + val pluginData = keyFor.?.all(ScopeFilter(inAnyProject)).value.flatten.filterNot(_ == null) + val externalData = externalProjectData(keyFor, productsKey).value + + val data = pluginData ++ externalData val outputDir = packageOutputDir.value val logger: SbtPluginLogger = new SbtPluginLogger(streams.value) val buildStructure = Keys.buildStructure.value @@ -113,4 +157,48 @@ trait PackagingKeysInit { res } + /** + * Sub-task that collects packaging data for external projects loaded via + * `RootProject` / `ProjectRef` — those not already covered by `keyFor`. + * + * External projects have their own builds and don't load sbt-idea-plugin, so + * `dumpDependencyStructure` is undefined for them and they are absent from `keyFor`'s data. + * Without this, their class files and library dependencies would never reach the plugin + * artifact, forcing consumers into `packageFileMappings` with explicit JARs (which breaks + * source-level debugging) and manual `libraryDependencies` pulls. + * + * We read project refs, names, class output, managed classpaths, library dependencies, and + * full dependency reports (`updateFull`) from ALL projects using standard SBT keys (available + * everywhere), then [[PackagingKeysInit.buildExternalProjectData]] turns the projects not in + * `keyFor` into [[SbtPackageProjectData]] defaulting to `MergeIntoParent()` — so their classes + * and libraries merge into the parent plugin artifact, just like `dependsOn` for normal + * (non-IJ) SBT projects. The full report (not null) lets `IvyLibraryExtractor` resolve + * transitive dependencies without special null-handling. + * + * `productsKey` is supplied by the caller to mirror the online/offline split that already + * exists for `dumpDependencyStructure(Offline)`: `packageMappings` passes `Compile / products` + * (compile-triggering, needed to actually package class files); `packageMappingsOffline` passes + * `Compile / productDirectories` (path-only). The offline variant runs during IntelliJ sync via + * `createIDEAArtifactXml`, so using `products` there would force a compile of every project on + * sync, and a compile error in any external `RootProject` would break sync entirely. + * + * For IJ plugins with no external `RootProject` dependencies this returns an empty sequence + * (no-op) — every project is already covered by `keyFor`. + */ + private def externalProjectData( + keyFor: TaskKey[SbtPackageProjectData], + productsKey: TaskKey[Seq[File]], + ): Def.Initialize[Task[Seq[SbtPackageProjectData]]] = Def.task { + val pluginRefs = keyFor.?.all(ScopeFilter(inAnyProject)).value.flatten.filterNot(_ == null).map(_.thisProject).toSet + val allRefs = thisProjectRef.all(ScopeFilter(inAnyProject)).value + val allNames = name.all(ScopeFilter(inAnyProject)).value + val allProducts = productsKey.all(ScopeFilter(inAnyProject)).value + val allClasspaths = (Runtime / managedClasspath).all(ScopeFilter(inAnyProject)).value + val allDefinedDeps = (Compile / libraryDependencies).all(ScopeFilter(inAnyProject)).value + val allReports = updateFull.all(ScopeFilter(inAnyProject)).value + PackagingKeysInit.buildExternalProjectData( + pluginRefs, allRefs, allNames, allProducts, allClasspaths, allDefinedDeps, allReports + ) + } + } diff --git a/packaging/src/main/scala/org/jetbrains/sbtidea/packaging/mappings/LinearMappingsBuilder.scala b/packaging/src/main/scala/org/jetbrains/sbtidea/packaging/mappings/LinearMappingsBuilder.scala index a263ade6..6d1c0816 100644 --- a/packaging/src/main/scala/org/jetbrains/sbtidea/packaging/mappings/LinearMappingsBuilder.scala +++ b/packaging/src/main/scala/org/jetbrains/sbtidea/packaging/mappings/LinearMappingsBuilder.scala @@ -14,11 +14,12 @@ class LinearMappingsBuilder(override val outputDir: File, log: PluginLogger) ext private val mappingsBuffer: mutable.Set[Mapping] = new mutable.TreeSet[Mapping]() - private def processNode(node: PackagedProjectNode): Unit = { + private def processNode(node: PackagedProjectNode, + rootLibraryMappings: Map[structure.ModuleKey, Option[String]]): Unit = { if (shouldSkip(node)) return val targetJar = processTarget(node) - processLibraries(node, targetJar) + processLibraries(node, targetJar, rootLibraryMappings) processFileMappings(node) } @@ -127,15 +128,25 @@ class LinearMappingsBuilder(override val outputDir: File, log: PluginLogger) ext to } - private def processLibraries(node: PackagedProjectNode, targetJar: File): Unit = { + private def processLibraries(node: PackagedProjectNode, targetJar: File, + rootLibraryMappings: Map[structure.ModuleKey, Option[String]]): Unit = { def mapping(jarFile: File, to: File): Mapping = if (node.packagingOptions.assembleLibraries) Mapping(jarFile, targetJar, node.mmd.copy(kind = MAPPING_KIND.LIB_ASSEMBLY)) else Mapping(jarFile, to, node.mmd.copy(kind = MAPPING_KIND.LIB)) + // Merge root project's libraryMappings as global defaults with this node's own mappings. + // Node-specific mappings override root mappings (node wins via ++ ordering). + // + // | Node libMapping | Root libMapping | Merged (root ++ node) | Result | + // |------------------------------|----------------------|------------------------------|-----------------| + // | empty (external project) | scala-.* -> None | scala-.* -> None | Scala excluded | + // | scala-.* -> None (subproject)| scala-.* -> None | scala-.* -> None | Same | + // | scala-reflect -> Some("lib/")| scala-.* -> None | scala-reflect -> Some("lib/")| Node wins | + // | empty (no exclusions wanted) | empty | empty | All included | val mappings: Map[structure.ModuleKey, Option[String]] = - node.packagingOptions.libraryMappings.toMap + rootLibraryMappings ++ node.packagingOptions.libraryMappings.toMap val invalidMappings = mappings .filterNot { case (key, _) => @@ -177,10 +188,26 @@ class LinearMappingsBuilder(override val outputDir: File, log: PluginLogger) ext override def buildMappings(nodes: Seq[PackagedProjectNode]): Mappings = { log.info(s"building mappings for ${nodes.size} nodes") - nodes.foreach(processNode) + val rootLibraryMappings = rootLibraryMappingsOf(nodes) + nodes.foreach(processNode(_, rootLibraryMappings)) mappingsBuffer.toSeq } + /** + * Returns the root (Standalone) project's `libraryMappings`, used by [[processLibraries]] as + * global defaults applied to every node. + * + * This lets a plugin author set `packageLibraryMappings` once on the root project and have it + * apply to external projects loaded via `dependsOn(RootProject(...))`, which don't run + * sbt-idea-plugin and therefore carry an empty `libraryMappings`. Returns an empty map when no + * Standalone node is present. + */ + private def rootLibraryMappingsOf(nodes: Seq[PackagedProjectNode]): Map[structure.ModuleKey, Option[String]] = + nodes + .find(_.packagingOptions.packageMethod.isInstanceOf[PackagingMethod.Standalone]) + .map(_.packagingOptions.libraryMappings.toMap) + .getOrElse(Map.empty) + private def getTopLevelJarPath(node: PackagedProjectNode): String = node.packagingOptions.packageMethod match { case PackagingMethod.Skip() => throw new MappingBuildException(s"$node cannot be a top-level project") diff --git a/packaging/src/test/scala-2.12/org/jetbrains/sbtidea/packaging/ExternalProjectPackagingTest.scala b/packaging/src/test/scala-2.12/org/jetbrains/sbtidea/packaging/ExternalProjectPackagingTest.scala new file mode 100644 index 00000000..89dfbc83 --- /dev/null +++ b/packaging/src/test/scala-2.12/org/jetbrains/sbtidea/packaging/ExternalProjectPackagingTest.scala @@ -0,0 +1,211 @@ +package org.jetbrains.sbtidea.packaging + +import org.jetbrains.sbtidea.PluginLogger +import org.jetbrains.sbtidea.packaging.mappings.LinearMappingsBuilder +import org.jetbrains.sbtidea.packaging.structure.{PackagingMethod => SPackagingMethod} +import org.jetbrains.sbtidea.structure.ModuleKey +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec + +import java.io.File + +/** + * Tests for external project packaging support. + * + * When an IntelliJ plugin project uses dependsOn(RootProject(...)) to depend on external + * SBT projects, those projects don't have sbt-idea-plugin enabled. The packaging system + * collects their data using standard SBT keys and includes them with MergeIntoParent(), + * so their class files and libraries are merged into the parent plugin artifact. + * + * These tests verify that the packaging system correctly handles external projects + * alongside normal plugin projects. + * + * Node fixtures come from [[PackagingTestNodes]], shared with `LinearMappingsBuilderMergeWarningsTest`. + */ +class ExternalProjectPackagingTest extends AnyWordSpec with Matchers with PackagingTestNodes { + + private val outputDir = new File("target/test-packaging") + + "External project packaging" should { + + "merge external project class files into parent plugin artifact" in { + val externalClassDir = new File("/tmp/external-project/target/classes") + val pluginNode = node("main-plugin", SPackagingMethod.Standalone("lib/main-plugin.jar", static = false)) + val externalNode = node( + "external-lib", + SPackagingMethod.MergeIntoParent(), + parents = Seq(pluginNode), + classRoots = Seq(externalClassDir) + ) + + val mappings = new LinearMappingsBuilder(outputDir, PluginLogger).buildMappings(Seq(externalNode, pluginNode)) + + // External project's class directory should be mapped to the parent's JAR + val externalMappings = mappings.filter(_.from == externalClassDir) + externalMappings should not be empty + externalMappings.head.to.toString should include("main-plugin.jar") + } + + "include external project libraries in packaging output" in { + val libraryJar = new File("/tmp/external-project/lib/some-library.jar") + val libraryKey = mkKey("org.example", "some-library", "1.0") + val library = new TestLibrary(libraryKey, Seq(libraryJar)) + + val pluginNode = node("main-plugin", SPackagingMethod.Standalone("lib/main-plugin.jar", static = false)) + val externalNode = node( + "external-lib", + SPackagingMethod.MergeIntoParent(), + parents = Seq(pluginNode), + libs = Seq(library) + ) + + val mappings = new LinearMappingsBuilder(outputDir, PluginLogger).buildMappings(Seq(externalNode, pluginNode)) + + // External project's library JAR should appear in the mappings + val libraryMappings = mappings.filter(_.from == libraryJar) + libraryMappings should not be empty + } + + "work normally when there are no external projects" in { + val pluginClassDir = new File("/tmp/plugin/target/classes") + val subprojectClassDir = new File("/tmp/subproject/target/classes") + + val pluginNode = node( + "main-plugin", + SPackagingMethod.Standalone("lib/main-plugin.jar", static = false), + classRoots = Seq(pluginClassDir) + ) + val subprojectNode = node( + "sub-module", + SPackagingMethod.MergeIntoParent(), + parents = Seq(pluginNode), + classRoots = Seq(subprojectClassDir) + ) + + val mappings = new LinearMappingsBuilder(outputDir, PluginLogger).buildMappings(Seq(subprojectNode, pluginNode)) + + // Both projects' classes should be mapped + mappings.exists(_.from == pluginClassDir) shouldBe true + mappings.exists(_.from == subprojectClassDir) shouldBe true + } + + "include all libraries from external projects when root has no exclusions" in { + val scalaLibJar = new File("/tmp/scala-library-3.7.4.jar") + val scalaLibKey = mkKey("org.scala-lang", "scala3-library_3", "3.7.4") + val scalaLib = new TestLibrary(scalaLibKey, Seq(scalaLibJar)) + + val appLibJar = new File("/tmp/cats-core.jar") + val appLibKey = mkKey("org.typelevel", "cats-core_3", "2.13.0") + val appLib = new TestLibrary(appLibKey, Seq(appLibJar)) + + // Both root and external project have empty libraryMappings — all libs included. + val pluginNode = node("main-plugin", SPackagingMethod.Standalone("lib/main-plugin.jar", static = false)) + val externalNode = node( + "external-lib", + SPackagingMethod.MergeIntoParent(), + parents = Seq(pluginNode), + libs = Seq(scalaLib, appLib), + libraryMappings = Seq.empty + ) + + val mappings = new LinearMappingsBuilder(outputDir, PluginLogger).buildMappings(Seq(externalNode, pluginNode)) + + mappings.exists(_.from == scalaLibJar) shouldBe true + mappings.exists(_.from == appLibJar) shouldBe true + } + + "exclude libraries from external projects when root project has exclusions" in { + val scalaLibJar = new File("/tmp/scala-library-3.7.4.jar") + val scalaLibKey = mkKey("org.scala-lang", "scala3-library_3", "3.7.4") + val scalaLib = new TestLibrary(scalaLibKey, Seq(scalaLibJar)) + + val appLibJar = new File("/tmp/cats-core.jar") + val appLibKey = mkKey("org.typelevel", "cats-core_3", "2.13.0") + val appLib = new TestLibrary(appLibKey, Seq(appLibJar)) + + // Root project excludes scala libraries via libraryMappings. + // External project has empty libMapping, so root's exclusions apply as defaults. + val scalaExclusions: Seq[(ModuleKey, Option[String])] = Seq(scalaLibKey -> None) + val pluginNode = node( + "main-plugin", + SPackagingMethod.Standalone("lib/main-plugin.jar", static = false), + libraryMappings = scalaExclusions + ) + val externalNode = node( + "external-lib", + SPackagingMethod.MergeIntoParent(), + parents = Seq(pluginNode), + libs = Seq(scalaLib, appLib), + libraryMappings = Seq.empty + ) + + val mappings = new LinearMappingsBuilder(outputDir, PluginLogger).buildMappings(Seq(externalNode, pluginNode)) + + // scala-library excluded by root's mappings, cats-core included + mappings.exists(_.from == scalaLibJar) shouldBe false + mappings.exists(_.from == appLibJar) shouldBe true + } + + "exclude scala when both root and node have the same exclusions" in { + val scalaLibJar = new File("/tmp/scala-library-3.7.4.jar") + val scalaLibKey = mkKey("org.scala-lang", "scala3-library_3", "3.7.4") + val scalaLib = new TestLibrary(scalaLibKey, Seq(scalaLibJar)) + + val appLibJar = new File("/tmp/cats-core.jar") + val appLibKey = mkKey("org.typelevel", "cats-core_3", "2.13.0") + val appLib = new TestLibrary(appLibKey, Seq(appLibJar)) + + // Both root and subproject exclude scala — should still work (no double-processing issues) + val scalaExclusions: Seq[(ModuleKey, Option[String])] = Seq(scalaLibKey -> None) + val pluginNode = node( + "main-plugin", + SPackagingMethod.Standalone("lib/main-plugin.jar", static = false), + libraryMappings = scalaExclusions + ) + val subprojectNode = node( + "sub-module", + SPackagingMethod.MergeIntoParent(), + parents = Seq(pluginNode), + libs = Seq(scalaLib, appLib), + libraryMappings = scalaExclusions + ) + + val mappings = new LinearMappingsBuilder(outputDir, PluginLogger).buildMappings(Seq(subprojectNode, pluginNode)) + + mappings.exists(_.from == scalaLibJar) shouldBe false + mappings.exists(_.from == appLibJar) shouldBe true + } + + "node-specific libraryMappings override root exclusions" in { + val scalaReflectJar = new File("/tmp/scala-reflect-2.13.15.jar") + val scalaReflectKey = mkKey("org.scala-lang", "scala-reflect", "2.13.15") + val scalaReflectLib = new TestLibrary(scalaReflectKey, Seq(scalaReflectJar)) + + // Root excludes all scala-lang, but subproject explicitly includes scala-reflect + val rootExclusions: Seq[(ModuleKey, Option[String])] = Seq( + mkKey("org.scala-lang", "scala-.*", ".*") -> None + ) + val nodeOverride: Seq[(ModuleKey, Option[String])] = Seq( + scalaReflectKey -> Some("lib/scala-reflect.jar") + ) + + val pluginNode = node( + "main-plugin", + SPackagingMethod.Standalone("lib/main-plugin.jar", static = false), + libraryMappings = rootExclusions + ) + val subprojectNode = node( + "sub-module", + SPackagingMethod.MergeIntoParent(), + parents = Seq(pluginNode), + libs = Seq(scalaReflectLib), + libraryMappings = nodeOverride + ) + + val mappings = new LinearMappingsBuilder(outputDir, PluginLogger).buildMappings(Seq(subprojectNode, pluginNode)) + + // Node's explicit include wins over root's exclusion + mappings.exists(_.from == scalaReflectJar) shouldBe true + } + } +} diff --git a/packaging/src/test/scala-2.12/org/jetbrains/sbtidea/packaging/LinearMappingsBuilderMergeWarningsTest.scala b/packaging/src/test/scala-2.12/org/jetbrains/sbtidea/packaging/LinearMappingsBuilderMergeWarningsTest.scala index 39bcef41..bd65eb76 100644 --- a/packaging/src/test/scala-2.12/org/jetbrains/sbtidea/packaging/LinearMappingsBuilderMergeWarningsTest.scala +++ b/packaging/src/test/scala-2.12/org/jetbrains/sbtidea/packaging/LinearMappingsBuilderMergeWarningsTest.scala @@ -1,8 +1,7 @@ package org.jetbrains.sbtidea.packaging import org.jetbrains.sbtidea.packaging.mappings.LinearMappingsBuilder -import org.jetbrains.sbtidea.packaging.structure.{PackagedProjectNode, PackagingMethod as SPackagingMethod, ProjectPackagingOptions} -import org.jetbrains.sbtidea.structure.{Library, ModuleKey} +import org.jetbrains.sbtidea.packaging.structure.{PackagingMethod as SPackagingMethod} import org.jetbrains.sbtidea.PluginLogger import org.jetbrains.sbtidea.CapturingLogger import org.scalatest.matchers.should.Matchers @@ -11,7 +10,7 @@ import org.scalatest.wordspec.AnyWordSpec import java.io.File //NOTE: the test was generated with Codex -class LinearMappingsBuilderMergeWarningsTest extends AnyWordSpec with Matchers { +class LinearMappingsBuilderMergeWarningsTest extends AnyWordSpec with Matchers with PackagingTestNodes { private val MergeWarningText = "will be merged into non-terminal" "LinearMappingsBuilder merge warnings" should { @@ -71,39 +70,6 @@ class LinearMappingsBuilderMergeWarningsTest extends AnyWordSpec with Matchers { } } - private def node( - name: String, - method: SPackagingMethod, - parents: Seq[PackagedProjectNode] = Seq.empty - ): TestNode = - new TestNode(name = name, parents0 = parents, packagingOptions = packagingOptions(method)) - - private def packagingOptions(method: SPackagingMethod): ProjectPackagingOptions = - new ProjectPackagingOptions { - override def packageMethod: SPackagingMethod = method - override def libraryMappings: Seq[(ModuleKey, Option[String])] = Seq.empty - override def libraryBaseDir: File = new File("lib") - override def fileMappings: Seq[(File, String)] = Seq.empty - override def shadePatterns: Seq[ShadePattern] = Seq.empty - override def excludeFilter: ExcludeFilter = ExcludeFilter.AllPass - override def additionalProjects: Seq[PackagedProjectNode] = Seq.empty - override def classRoots: Seq[File] = Seq.empty - override def assembleLibraries: Boolean = false - } - - private final class TestNode( - override val name: String, - parents0: Seq[PackagedProjectNode], - override val packagingOptions: ProjectPackagingOptions - ) extends PackagedProjectNode { - override val rootProjectName: Option[String] = None - override val parents: Seq[PackagedProjectNode] = parents0 - override val children: Seq[PackagedProjectNode] = Seq.empty - override val libs: Seq[Library] = Seq.empty - - override def toString: String = s"{$name}" - } - private def assertContainsWarningText(messages: Seq[String], warningText: String): Unit = messages.exists(_.contains(warningText)) shouldBe true diff --git a/packaging/src/test/scala-2.12/org/jetbrains/sbtidea/packaging/PackagingKeysInitTest.scala b/packaging/src/test/scala-2.12/org/jetbrains/sbtidea/packaging/PackagingKeysInitTest.scala new file mode 100644 index 00000000..d4406acf --- /dev/null +++ b/packaging/src/test/scala-2.12/org/jetbrains/sbtidea/packaging/PackagingKeysInitTest.scala @@ -0,0 +1,142 @@ +package org.jetbrains.sbtidea.packaging + +import org.jetbrains.sbtidea.packaging.structure.sbtImpl.SbtPackageProjectData +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec +import sbt._ + +import java.io.File +import java.net.URI + +/** + * Unit tests for PackagingKeysInit.buildExternalProjectData. + * + * The interesting behavior is that the helper is agnostic to which SBT task supplied + * `allProducts` — callers are free to pass values from `Compile / products` (for real + * packaging, where class files must exist on disk) or from `Compile / productDirectories` + * (for the offline path used during IntelliJ sync via createIDEAArtifactXml, where + * forcing a compile of every project would break sync on a dependent compile error). + * + * Locking that contract here means the call sites in PackagingKeysInit — which wire + * `productDirectories` for the offline path — cannot regress without this test failing. + */ +class PackagingKeysInitTest extends AnyWordSpec with Matchers { + + private def mkRef(name: String): ProjectRef = + ProjectRef(new URI("file:///tmp/build/"), name) + + "buildExternalProjectData" should { + + "pass productDirectories through unchanged for external projects (offline path)" in { + val pluginRef = mkRef("ij-plugin") + val externalRef = mkRef("external-lib") + + val externalProductDirs = Seq(new File("/tmp/external-lib/target/classes")) + + val result = PackagingKeysInit.buildExternalProjectData( + pluginRefs = Set(pluginRef), + allRefs = Seq(pluginRef, externalRef), + allNames = Seq("ij-plugin", "external-lib"), + allProducts = Seq(Seq(new File("/tmp/ij-plugin/target/classes")), externalProductDirs), + allClasspaths = Seq(Nil, Nil), + allDefinedDeps = Seq(Nil, Nil), + allReports = Seq(null, null), + ) + + // pluginRef already covered by pluginData — only external project should appear + result.map(_.thisProject) shouldBe Seq(externalRef) + // and whatever the caller supplied for allProducts(i) must reach productDirs verbatim + result.head.productDirs shouldBe externalProductDirs + } + + "pass products through unchanged for external projects (online path)" in { + // Same contract from the online direction: real compiled outputs reach productDirs + // verbatim. If anyone later inlines the productsKey back to (Compile / products), + // both this and the offline-path test still pass — but the offline-path comment + // and call-site wiring stay the line of defense. + val pluginRef = mkRef("ij-plugin") + val externalRef = mkRef("external-lib") + + val externalCompiledOutputs = Seq( + new File("/tmp/external-lib/target/classes"), + new File("/tmp/external-lib/target/extra-classes"), + ) + + val result = PackagingKeysInit.buildExternalProjectData( + pluginRefs = Set(pluginRef), + allRefs = Seq(pluginRef, externalRef), + allNames = Seq("ij-plugin", "external-lib"), + allProducts = Seq(Nil, externalCompiledOutputs), + allClasspaths = Seq(Nil, Nil), + allDefinedDeps = Seq(Nil, Nil), + allReports = Seq(null, null), + ) + + result.head.productDirs shouldBe externalCompiledOutputs + } + + "return empty when every project is already covered by pluginData" in { + val a = mkRef("a") + val b = mkRef("b") + + val result = PackagingKeysInit.buildExternalProjectData( + pluginRefs = Set(a, b), + allRefs = Seq(a, b), + allNames = Seq("a", "b"), + allProducts = Seq(Nil, Nil), + allClasspaths = Seq(Nil, Nil), + allDefinedDeps = Seq(Nil, Nil), + allReports = Seq(null, null), + ) + + result shouldBe empty + } + + "preserve index correlation across multiple external projects" in { + val pluginRef = mkRef("ij-plugin") + val extA = mkRef("ext-a") + val extB = mkRef("ext-b") + + val productsA = Seq(new File("/tmp/ext-a/target/classes")) + val productsB = Seq(new File("/tmp/ext-b/target/classes")) + val namesIn = Seq("ij-plugin", "ext-a", "ext-b") + + val result: Seq[SbtPackageProjectData] = PackagingKeysInit.buildExternalProjectData( + pluginRefs = Set(pluginRef), + allRefs = Seq(pluginRef, extA, extB), + allNames = namesIn, + allProducts = Seq(Nil, productsA, productsB), + allClasspaths = Seq(Nil, Nil, Nil), + allDefinedDeps = Seq(Nil, Nil, Nil), + allReports = Seq(null, null, null), + ) + + // Each external project's data must come from the same index across allRefs/allNames/allProducts/etc. + val byRef = result.map(d => d.thisProject -> d).toMap + byRef(extA).thisProjectName shouldBe "ext-a" + byRef(extA).productDirs shouldBe productsA + byRef(extB).thisProjectName shouldBe "ext-b" + byRef(extB).productDirs shouldBe productsB + } + + "default external projects to MergeIntoParent() with no opinionated libMappings" in { + val pluginRef = mkRef("ij-plugin") + val externalRef = mkRef("external-lib") + + val result = PackagingKeysInit.buildExternalProjectData( + pluginRefs = Set(pluginRef), + allRefs = Seq(pluginRef, externalRef), + allNames = Seq("ij-plugin", "external-lib"), + allProducts = Seq(Nil, Nil), + allClasspaths = Seq(Nil, Nil), + allDefinedDeps = Seq(Nil, Nil), + allReports = Seq(null, null), + ) + + val ext = result.head + ext.packageMethod shouldBe PackagingMethod.MergeIntoParent() + ext.libMapping shouldBe empty + ext.shadePatterns shouldBe empty + } + } +} diff --git a/packaging/src/test/scala-2.12/org/jetbrains/sbtidea/packaging/PackagingTestNodes.scala b/packaging/src/test/scala-2.12/org/jetbrains/sbtidea/packaging/PackagingTestNodes.scala new file mode 100644 index 00000000..51363e58 --- /dev/null +++ b/packaging/src/test/scala-2.12/org/jetbrains/sbtidea/packaging/PackagingTestNodes.scala @@ -0,0 +1,75 @@ +package org.jetbrains.sbtidea.packaging + +import org.jetbrains.sbtidea.packaging.structure.{PackagedProjectNode, PackagingMethod => SPackagingMethod, ProjectPackagingOptions} +import org.jetbrains.sbtidea.structure.{Library, ModuleKey} +import org.jetbrains.sbtidea.structure.sbtImpl.ModuleKeyImpl +import sbt.ModuleID + +import java.io.File + +/** + * Shared in-memory [[PackagedProjectNode]] fixtures for [[org.jetbrains.sbtidea.packaging.mappings.LinearMappingsBuilder]] + * tests. Both `LinearMappingsBuilderMergeWarningsTest` and `ExternalProjectPackagingTest` build the + * same kind of synthetic node graph (a node with a packaging method, parents, class roots, libs and + * library mappings) and run it through `LinearMappingsBuilder.buildMappings`. Keeping one + * parameterized `node(...)` here avoids the divergent copies that previously lived in each test. + * + * Every constructor parameter except `name`/`method` defaults to empty, so callers only supply the + * fields a given scenario cares about. + */ +trait PackagingTestNodes { + + protected def node( + name: String, + method: SPackagingMethod, + parents: Seq[PackagedProjectNode] = Seq.empty, + classRoots: Seq[File] = Seq.empty, + libs: Seq[Library] = Seq.empty, + libraryMappings: Seq[(ModuleKey, Option[String])] = Seq.empty + ): TestNode = + new TestNode( + name = name, + parents0 = parents, + libs0 = libs, + packagingOptions = packagingOptions(method, classRoots, libraryMappings) + ) + + protected def packagingOptions( + method: SPackagingMethod, + classRoots0: Seq[File], + libraryMappings0: Seq[(ModuleKey, Option[String])] + ): ProjectPackagingOptions = + new ProjectPackagingOptions { + override def packageMethod: SPackagingMethod = method + override def libraryMappings: Seq[(ModuleKey, Option[String])] = libraryMappings0 + override def libraryBaseDir: File = new File("lib") + override def fileMappings: Seq[(File, String)] = Seq.empty + override def shadePatterns: Seq[ShadePattern] = Seq.empty + override def excludeFilter: ExcludeFilter = ExcludeFilter.AllPass + override def additionalProjects: Seq[PackagedProjectNode] = Seq.empty + override def classRoots: Seq[File] = classRoots0 + override def assembleLibraries: Boolean = false + } + + protected final class TestNode( + override val name: String, + parents0: Seq[PackagedProjectNode], + libs0: Seq[Library], + override val packagingOptions: ProjectPackagingOptions + ) extends PackagedProjectNode { + override val rootProjectName: Option[String] = None + override val parents: Seq[PackagedProjectNode] = parents0 + override val children: Seq[PackagedProjectNode] = Seq.empty + override val libs: Seq[Library] = libs0 + + override def toString: String = s"{$name}" + } + + protected def mkKey(org: String, name: String, rev: String): ModuleKey = + ModuleKeyImpl(ModuleID(org, name, rev), Map.empty) + + protected class TestLibrary( + override val key: ModuleKey, + override val jarFiles: Seq[File] + ) extends Library +}