Skip to content

Fix crash on IntelliJ import with dependsOn(RootProject(...)) (#146) - #147

Merged
Dmitrii Naumenko (unkarjedy) merged 2 commits into
JetBrains:masterfrom
teeckoo:handle-external-project-refs
Jun 1, 2026
Merged

Fix crash on IntelliJ import with dependsOn(RootProject(...)) (#146)#147
Dmitrii Naumenko (unkarjedy) merged 2 commits into
JetBrains:masterfrom
teeckoo:handle-external-project-refs

Conversation

@teeckoo

@teeckoo teeckoo (teeckoo) commented Mar 22, 2026

Copy link
Copy Markdown
Contributor

Fix dependsOn(RootProject(...)) crash and packaging (#146)

Fixes two crashes during IntelliJ import when an IJ plugin project uses dependsOn(RootProject(...)) to depend on a normal SBT project, and ensures external project class files and libraries are included in the plugin artifact.

Crash fixes

SbtProjectStructureExtractorBase.scalatopoSortRefs crashed with NoSuchElementException when buildDependencies.classpathRefs returned an external ProjectRef not in projectMap. Changed to projectMap.get(root) with pattern match; filter external refs in topoSortRefs, revProjectMap, and collectChildren.

Init.scalacreateIDEARunConfiguration aggregated intellijExtraRuntimePluginsInTests via ScopeFilter(inDependencies(ThisProject)), which reached external projects without sbt-idea-plugin. Added defaults for intellijPlugins and intellijExtraRuntimePluginsInTests in globalSettings.

External project packaging

PackagingKeysInit.scala — External projects don't have sbt-idea-plugin in their builds, so dumpDependencyStructure was undefined for them and they were excluded from the packaging graph. packageMappingsImpl now collects packaging data for all projects using standard SBT keys (products, managedClasspath, libraryDependencies, updateFull). External projects default to MergeIntoParent() with empty libMapping, so their class files and libraries are automatically merged into the plugin artifact.

Root project packageLibraryMappings as global filter

LinearMappingsBuilder.scalaprocessLibraries now merges the root (Standalone) project's libraryMappings as global defaults with each node's own mappings. Node-specific mappings override root mappings. This lets the plugin author set packageLibraryMappings once on the root project and have it apply to all nodes — including external projects that have no packageLibraryMappings of their own.

README.md — Added "Source-Level Debugging with packageArtifactDynamic" section documenting the three packaging tasks, external project support, and global packageLibraryMappings behavior.

Tests

  • 5 new tests in SbtProjectStructureExtractorBaseTest.scala — verify structure extraction filters external refs without crashing
  • 7 new tests in ExternalProjectPackagingTest.scala — verify external project classes and libraries are included in packaging, root exclusions filter child libraries, node overrides root, and no-op when no external deps exist

Result

lazy val myLibrary = RootProject(file("../my-library"))
lazy val myPlugin = project.in(file("."))
  .enablePlugins(SbtIdeaPlugin)
  .dependsOn(myLibrary)

No packageFileMappings or manual libraryDependencies pull needed. packageArtifactDynamic enables source-level debugging across module boundaries. packageLibraryMappings on the root project controls library exclusions globally — including external projects:

packageLibraryMappings ++= Seq(
  "org.scala-lang" % "scala.*" % ".*" -> None,
  "org.scala-lang.modules" % "scala.*" % ".*" -> None
)

Root mappings act as defaults; node-specific mappings override:

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 empty empty All included

For projects that don't use dependsOn(RootProject(...)), all subprojects already have sbt-idea-plugin enabled and are covered by dumpDependencyStructure. The new external project collection produces an empty list, so the packaging behavior is identical to before.

Minimal reproducible example

https://github.com/collaboncode/minimal-ij-plugin-setup

@teeckoo
teeckoo (teeckoo) force-pushed the handle-external-project-refs branch 3 times, most recently from e3bbdcf to fbf9da3 Compare March 23, 2026 09:38
@teeckoo
teeckoo (teeckoo) force-pushed the handle-external-project-refs branch from 1764a49 to 98d85ff Compare April 1, 2026 06:11
@teeckoo
teeckoo (teeckoo) force-pushed the handle-external-project-refs branch from 98d85ff to 18da64f Compare April 15, 2026 18:03

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))
protected lazy val revProjectMap: Seq[(ProjectRef, ProjectRef)] = projectsData.flatMap(x => buildDependencies.classpathRefs(x.thisProject).filter(projectMap.contains).map(_ -> x.thisProject))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you leave a line comment here with explanation why filtering as per the projectMap is needed? For people not in the context this bit can be useful

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ideally also needs a comment (Scaladoc?) to the projectMap

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment might also refer to the existing tests (see example here...)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, all three:

  • Inline comment on revProjectMap explaining the .filter(projectMap.contains) drops reverse edges to external dependsOn(RootProject(...)) projects — they
    aren't in projectMap, so keeping them would crash the later projectMap(ref) lookup in collectParents (the dependsOn(RootProject(...)) crashes on IntelliJ import when depending on a non-IntelliJ SBT project #146 bug).
  • Scaladoc on projectMap itself: it holds only refs from the current build (projectsData); external refs are deliberately absent, which is why every lookup in
    the trait is guarded with projectMap.contains / projectMap.get.
  • Both now point to the renamed SbtProjectStructureExtractorExternalRefsTest as the example.

log.warn(s"skipping external project ref not part of the current build: $root")
queue
case Some(data) =>
if (!queue.contains(root)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(minor) I would extract this branch to a method with a meaningful name

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extracted into private def enqueueWithDependencies(data, root, queue). topoSortRefs is now just the projectMap.get(root) match (skip-external vs. recurse),
with the recursion body in the named method under its own Scaladoc.


override def collectChildren(node: NodeType, data: ProjectDataType): Seq[NodeType] = {
val childRefs = buildDependencies.classpathRefs(node.ref)
val childRefs = buildDependencies.classpathRefs(node.ref).filter(projectCache.contains)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, a some small comment for the filtering would be nice to have, with reference to example

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a short comment on the .filter(projectCache.contains) in collectChildren, mirroring the revProjectMap rationale and pointing to #146 and
SbtProjectStructureExtractorExternalRefsTest.

// for all nodes. This allows the plugin author to set packageLibraryMappings once
// on the root project and have it apply to external projects loaded via RootProject
// that don't have sbt-idea-plugin and therefore have empty libMapping.
val rootLibraryMappings = nodes

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth extracting to a method with a Scaladoc

@teeckoo teeckoo (teeckoo) May 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extracted into private def rootLibraryMappingsOf(nodes): Map[ModuleKey, Option[String]] with a Scaladoc explaining it returns the root (Standalone) project's
libraryMappings used as global defaults by processLibraries, and that it returns an empty map when there's no Standalone node.

val data = keyFor.?.all(ScopeFilter(inAnyProject)).value.flatten.filterNot(_ == null)
val pluginData = keyFor.?.all(ScopeFilter(inAnyProject)).value.flatten.filterNot(_ == null)

// Collect packaging data for external projects loaded via RootProject/ProjectRef.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand how hard it is to work with SBT code and especially SBT tasks & all the macro magic inside.

Still, if you would manage to extract his to a separate subtask, this would simply the code

@teeckoo teeckoo (teeckoo) May 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The cross-project collection (the *.all(ScopeFilter(inAnyProject)) reads + assembly) is now a separate sub-task, private def externalProjectData(keyFor, productsKey): Def.Initialize[Task[Seq[SbtPackageProjectData]]], with the explanatory Scaladoc moved onto it. packageMappingsImpl reduces to val externalData = externalProjectData(keyFor, productsKey).value. The online/offline productsKey split (products vs productDirectories) is preserved exactly, and the pure
assembly stays in PackagingKeysInit.buildExternalProjectData (unit-tested by PackagingKeysInitTest).

import java.io.File
import java.net.URI

class SbtProjectStructureExtractorBaseTest extends AnyFunSuite with Matchers {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why "*BaseTest". ATM it sounds a little misleading
With that name we usually expect some abstract class that is used as a base for other concrete tests.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And could you please leave a Scaladoc with the description of what the test tests and how it's different from other tests / why other tests can't be reused

@teeckoo teeckoo (teeckoo) May 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed the class and file to SbtProjectStructureExtractorExternalRefsTest — it now names what's verified (the trait's handling of external ProjectRefs) instead
of implying an abstract base for other tests.

Also added a class Scaladoc describing what it covers (regression coverage for #146) and why the existing integration-style structure tests can't: those load a real
sbt build where every project shares one build, so no ref is ever "external". Here we drive the trait with a synthetic graph — the only way to produce a ref that's
on the classpath but missing from projectMap.

}

/**
* Test-friendly extractor that uses a simple adjacency map instead of requiring

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like quite a big class with quite some code duplication.

As per the comment the only reason it exists is because BuildDependencies has private constructor.
But from what I see the factory method sbt.internal.BuildDependencies.apply is public and you can use it. Why not using it?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even if that still doesn't work, I would rather adopt org.jetbrains.sbtidea.structure.sbtImpl.SbtProjectStructureExtractorBase to be more test friendly and decouple it from BuildDependencies by introducing an interface exposing what we actually need from BuildDependencies
(AFAIU it's just sbt.internal.BuildDependencies#classpathRefs)

@teeckoo teeckoo (teeckoo) May 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call on the factory — it works, so TestExtractor no longer overrides revProjectMap / topoSortRefs / collectChildren. The test now builds a real
BuildDependencies via BuildDependencies.apply(classpath, aggregate) (small mkBuildDependencies helper that wraps each dep as ResolvedClasspathDependency(_, None)), and TestExtractor implements only the genuinely abstract members (buildStub, updateNode, collectLibraries). The duplication is gone and the
production traversal logic runs verbatim.

Since the factory worked, I didn't introduce the classpathRefs-only interface to decouple the extractor from BuildDependencies — that avoided touching production
code purely for test ergonomics. Happy to add it if you'd prefer the decoupling on principle; let me know.

}
}

private def node(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Were these test utilities written based on example in LinearMappingsBuilderMergeWarningsTest?

I now see duplication and it becomes messy.
I would expect some cleaning up an dunification

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes — those helpers were copied from LinearMappingsBuilderMergeWarningsTest. Unified them into a single shared PackagingTestNodes trait (one parameterized
node(...) / packagingOptions(...) plus TestNode / TestLibrary / mkKey, all optional fields defaulting to empty). Both ExternalProjectPackagingTest and
LinearMappingsBuilderMergeWarningsTest now mix it in and dropped their local copies.

…ins#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.
@teeckoo
teeckoo (teeckoo) force-pushed the handle-external-project-refs branch from 18da64f to acbcd38 Compare May 31, 2026 02:27
@teeckoo

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review, Dmitrii Naumenko (@unkarjedy) — all comments addressed. A couple of heads-ups first:

  • The branch was rebased onto v5.1.7 (it was on v5.1.5), so this is a force-push. The force-push compare link will mix rebase noise with the review fixes —
    sorry about that.
  • Commit 1 (the crash fix) is substantively unchanged — only the comments/Scaladoc you asked for plus a pure method-extraction refactor; no behavioural change.
  • Review fixes are folded into the two existing commits rather than added as a third, to keep the history clean. The per-thread replies say what changed.

What changed, by area:

  • SbtProjectStructureExtractorBase — documented projectMap (Scaladoc) and the external-ref filtering on revProjectMap / collectChildren (both reference
    dependsOn(RootProject(...)) crashes on IntelliJ import when depending on a non-IntelliJ SBT project #146 and the test); extracted the recursion branch into a named enqueueWithDependencies(...).
  • Structure-extractor test — renamed to SbtProjectStructureExtractorExternalRefsTest, added a class Scaladoc, and dropped the hand-rolled TestExtractor
    overrides in favour of a real BuildDependencies built via the public BuildDependencies.apply factory.
  • LinearMappingsBuilder — extracted the root-library-mappings lookup into a documented rootLibraryMappingsOf(nodes).
  • PackagingKeysInit — extracted the cross-project external-data collection into a separate sub-task, externalProjectData(keyFor, productsKey);
    packageMappingsImpl is now a handful of lines.
  • Packaging tests — unified the duplicated node fixtures into a shared PackagingTestNodes trait, mixed into both ExternalProjectPackagingTest and
    LinearMappingsBuilderMergeWarningsTest.

core/test (5) and packaging/test (20) are green; all modules compile.

@unkarjedy

Copy link
Copy Markdown
Member

teeckoo (@teeckoo) Thanks!
I will merge it publish a new version soon

@unkarjedy
Dmitrii Naumenko (unkarjedy) merged commit 5170c7e into JetBrains:master Jun 1, 2026
1 check failed
Dmitrii Naumenko (unkarjedy) added a commit that referenced this pull request Jun 1, 2026
This is an appended fix for the external RootProject packaging fix merged in PR #147 for issue #146.

It covers newly found edge cases where root packageLibraryMappings target an external-only non-Scala library or are read from the wrong Standalone node when another standalone node appears before the actual root plugin project.

Root mappings now come from the actual root plugin project and are validated against the full packaging graph, while node-specific mappings remain validated against node-local libraries.

Co-authored-by: Codex <codex@openai.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants