diff --git a/build.sbt b/build.sbt
index 3b5694bc7..ef647f110 100644
--- a/build.sbt
+++ b/build.sbt
@@ -5,6 +5,7 @@ import local.Scripted
import java.nio.file.{ Files, Path => JPath }
import java.util.Locale
import sbt.internal.inc.Analysis
+import sbt.Tags
import com.eed3si9n.jarjarabrams.ModuleCoordinate
// ThisBuild settings take lower precedence,
@@ -74,6 +75,10 @@ def commonSettings: Seq[Setting[?]] = Def.settings(
testFrameworks += TestFramework("hedgehog.sbt.Framework"),
testFrameworks += TestFramework("verify.runner.Framework"),
Global / concurrentRestrictions += Utils.testExclusiveRestriction,
+ // On Windows, limit to one task at a time to avoid OverlappingFileLockException when
+ // multiple tasks (e.g. scalafix plugin and sbt Coursier) write to the same cache.
+ Global / concurrentRestrictions ++= (if (scala.util.Properties.isWin) Seq(Tags.limitAll(1))
+ else Nil),
Test / testOptions += Tests.Argument(TestFrameworks.ScalaCheck, "-w", "1"),
Test / testOptions += Tests.Argument(TestFrameworks.ScalaCheck, "-verbosity", "2"),
compile / javacOptions ++= Seq("-Xlint", "-Xlint:-serial"),
diff --git a/lm-coursier/src/main/scala/lmcoursier/CoursierDependencyResolution.scala b/lm-coursier/src/main/scala/lmcoursier/CoursierDependencyResolution.scala
index f0334c543..87e4fb3f1 100644
--- a/lm-coursier/src/main/scala/lmcoursier/CoursierDependencyResolution.scala
+++ b/lm-coursier/src/main/scala/lmcoursier/CoursierDependencyResolution.scala
@@ -28,8 +28,7 @@ import lmcoursier.syntax.*
import sbt.internal.librarymanagement.IvySbt
import sbt.librarymanagement.*
import sbt.util.Logger
-import coursier.core.Dependency
-import coursier.core.Publication
+import coursier.core.{ BomDependency, Dependency, Publication }
import scala.util.{ Try, Failure }
@@ -217,14 +216,42 @@ class CoursierDependencyResolution(
val interProjectRepo = InterProjectRepository(interProjectDependencies)
val extraProjectsRepo = InterProjectRepository(extraProjects)
- val dependencies = module0.dependencies
+ // BOM (Bill of Materials): deps with only pom artifact (e.g. .pomOnly()) go to Resolve.addBom (sbt#4531)
+ def isBom(m: ModuleID): Boolean =
+ m.explicitArtifacts.nonEmpty && m.explicitArtifacts.forall(_.`type` == "pom")
+ val (bomModules, regularModules) = module0.dependencies.partition(isBom)
+ val boms: Seq[BomDependency] = bomModules.map { m =>
+ val (mod, ver) =
+ FromSbt.moduleVersion(
+ m,
+ sv,
+ sbv,
+ optionalCrossVer = true,
+ projectPlatform = projectPlatform
+ )
+ BomDependency(ToCoursier.module(mod), ver, Configuration.empty)
+ }
+ // Coursier fills version from BOM only when versionConstraint is empty (Resolution.processedRootDependencies).
+ // So for deps with "*" or "" and BOMs present, pass empty version so BOM can supply it (sbt#4531).
+ val dependencies = regularModules
.flatMap { d =>
- // crossVersion sometimes already taken into account (when called via the update task), sometimes not
- // (e.g. sbt-dotty 0.13.0-RC1)
- FromSbt.dependencies(d, sv, sbv, optionalCrossVer = true)
+ FromSbt.dependencies(d, sv, sbv, optionalCrossVer = true, projectPlatform = projectPlatform)
}
.map { (config, dep) =>
- (ToCoursier.configuration(config), ToCoursier.dependency(dep))
+ val depForResolve =
+ if (boms.nonEmpty && (dep.version == "*" || dep.version.isEmpty))
+ lmcoursier.definitions.Dependency(
+ dep.module,
+ "",
+ dep.configuration,
+ dep.exclusions,
+ dep.publication,
+ dep.optional,
+ dep.transitive
+ )
+ else
+ dep
+ (ToCoursier.configuration(config), ToCoursier.dependency(depForResolve))
}
val orderedConfigs = Inputs
@@ -275,6 +302,7 @@ class CoursierDependencyResolution(
strictOpt = conf.strict.map(ToCoursier.strict),
missingOk = conf.missingOk,
retry = conf.retry.getOrElse(ResolutionParams.defaultRetry),
+ boms = boms,
)
def artifactsParams(resolutions: Map[Configuration, Resolution]): ArtifactsParams =
diff --git a/lm-coursier/src/main/scala/lmcoursier/internal/ResolutionParams.scala b/lm-coursier/src/main/scala/lmcoursier/internal/ResolutionParams.scala
index b1e6aab50..784d58e31 100644
--- a/lm-coursier/src/main/scala/lmcoursier/internal/ResolutionParams.scala
+++ b/lm-coursier/src/main/scala/lmcoursier/internal/ResolutionParams.scala
@@ -31,7 +31,8 @@ final case class ResolutionParams(
params: coursier.params.ResolutionParams,
strictOpt: Option[Strict],
missingOk: Boolean,
- retry: (FiniteDuration, Int)
+ retry: (FiniteDuration, Int),
+ boms: Seq[BomDependency] = Nil
) {
lazy val allConfigExtends: Map[Configuration, Set[Configuration]] = {
@@ -97,9 +98,10 @@ final case class ResolutionParams(
a14,
a15,
a16,
- a17
+ a17,
+ a18
) =>
- (a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17).##
+ (a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17, a18).##
}
// ResolutionParams.unapply(this).get.##
diff --git a/lm-coursier/src/main/scala/lmcoursier/internal/ResolutionRun.scala b/lm-coursier/src/main/scala/lmcoursier/internal/ResolutionRun.scala
index d74bfbd21..28506498b 100644
--- a/lm-coursier/src/main/scala/lmcoursier/internal/ResolutionRun.scala
+++ b/lm-coursier/src/main/scala/lmcoursier/internal/ResolutionRun.scala
@@ -99,6 +99,7 @@ object ResolutionRun {
dep
}
)
+ .withBoms(params.boms)
.withRepositories(repositories)
.withResolutionParams(
params.params
diff --git a/lm-ivy/src/main/scala/sbt/internal/librarymanagement/MakePom.scala b/lm-ivy/src/main/scala/sbt/internal/librarymanagement/MakePom.scala
index 17246e699..819e8fe57 100644
--- a/lm-ivy/src/main/scala/sbt/internal/librarymanagement/MakePom.scala
+++ b/lm-ivy/src/main/scala/sbt/internal/librarymanagement/MakePom.scala
@@ -97,8 +97,18 @@ class MakePom(val log: Logger) {
{extra}
{
val deps = depsInConfs(module, configurations)
+ val (bomDeps, regularDeps) =
+ deps.partition(d =>
+ d.getAllDependencyArtifacts.nonEmpty &&
+ d.getAllDependencyArtifacts.forall(_.getType == Artifact.PomType)
+ )
makeProperties(module, deps) ++
- makeDependencies(deps, includeTypes, ArraySeq.unsafeWrapArray(module.getAllExcludeRules))
+ makeDependencyManagement(bomDeps) ++
+ makeDependencies(
+ regularDeps,
+ includeTypes,
+ ArraySeq.unsafeWrapArray(module.getAllExcludeRules)
+ )
}
{makeRepositories(ivy.getSettings, allRepositories, filterRepositories)}
)
@@ -238,6 +248,28 @@ class MakePom(val log: Logger) {
}
val IgnoreTypes: Set[String] = Set(Artifact.SourceType, Artifact.DocType, Artifact.PomType)
+ /** BOM (Bill of Materials) deps: output under <dependencyManagement> with type pom, scope import (sbt#4531). */
+ def makeDependencyManagement(dependencies: Seq[DependencyDescriptor]): NodeSeq =
+ if (dependencies.isEmpty)
+ NodeSeq.Empty
+ else
+
+
+ {dependencies.map(makeBomDependencyElem)}
+
+
+
+ def makeBomDependencyElem(dependency: DependencyDescriptor): Elem = {
+ val mrid = dependency.getDependencyRevisionId
+
+ {mrid.getOrganisation}
+ {mrid.getName}
+ {makeDependencyVersion(mrid.getRevision)}
+ pom
+ import
+
+ }
+
def makeDependencies(
dependencies: Seq[DependencyDescriptor],
includeTypes: Set[String],
@@ -301,16 +333,22 @@ class MakePom(val log: Logger) {
excludes: Seq[ExcludeRule]
): Elem = {
val mrid = dependency.getDependencyRevisionId
-
- {mrid.getOrganisation}
- {mrid.getName}
- {makeDependencyVersion(mrid.getRevision)}
- {scopeElem(scope)}
- {optionalElem(optional)}
- {classifierElem(classifier)}
- {typeElem(tpe)}
- {exclusions(dependency, excludes)}
-
+ val rev = mrid.getRevision
+ val versionNode: NodeSeq =
+ if (rev == null || rev == "*" || rev.isEmpty) NodeSeq.Empty
+ else {makeDependencyVersion(rev)}
+ val result: Elem =
+
+ {mrid.getOrganisation}
+ {mrid.getName}
+ {versionNode}
+ {scopeElem(scope)}
+ {optionalElem(optional)}
+ {classifierElem(classifier)}
+ {typeElem(tpe)}
+ {exclusions(dependency, excludes)}
+
+ result
}
def artifactType(artifact: DependencyArtifactDescriptor): Option[String] =
diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala
index 78c19c441..abe96430b 100644
--- a/main/src/main/scala/sbt/Defaults.scala
+++ b/main/src/main/scala/sbt/Defaults.scala
@@ -2973,6 +2973,24 @@ object Classpaths {
deliver := deliverTask(makeIvyXmlConfiguration).value,
deliverLocal := deliverTask(makeIvyXmlLocalConfiguration).value,
makeIvyXml := deliverTask(makeIvyXmlConfiguration).value,
+ resolvedDependencies := Def.task {
+ val report = update.value
+ val deps = allDependencies.value
+ val starDeps = deps.filter(d => d.revision == "*" || d.revision.isEmpty)
+ val compileModules = report.configurations
+ .find(_.configuration.name == "compile")
+ .toVector
+ .flatMap(_.modules)
+ starDeps.flatMap { d =>
+ compileModules
+ .find(m =>
+ m.module.organization == d.organization &&
+ m.module.name == d.name &&
+ !m.evicted
+ )
+ .map(m => d.withRevision(m.module.revision))
+ }.distinct
+ }.value,
publish := publishOrSkip(publishConfiguration, publish / skip).value,
publishLocal := LibraryManagement.ivylessPublishLocalTask.value,
publishM2 := publishOrSkip(publishM2Configuration, publishM2 / skip).value,
diff --git a/main/src/main/scala/sbt/Keys.scala b/main/src/main/scala/sbt/Keys.scala
index 855f5c467..c0e2049f8 100644
--- a/main/src/main/scala/sbt/Keys.scala
+++ b/main/src/main/scala/sbt/Keys.scala
@@ -566,6 +566,8 @@ object Keys {
val deliverLocal = taskKey[File]("Generates the Ivy file for publishing to the local repository.").withRank(BTask)
// makeIvyXml is currently identical to the confusingly-named "deliver", which may be deprecated in the future
val makeIvyXml = taskKey[File]("Generates the Ivy file for publishing to a repository.").withRank(BTask)
+ /** BOM-resolved ModuleIDs for deps that had \"*\" (version from BOM); emitted as forced deps in published ivy.xml (sbt#4531). */
+ val resolvedDependencies = taskKey[Seq[ModuleID]]("")
val publish = taskKey[Unit]("Publishes artifacts to a repository.").withRank(APlusTask)
val publishLocal = taskKey[Unit]("Publishes artifacts to the local Ivy repository.").withRank(APlusTask)
val publishM2 = taskKey[Unit]("Publishes artifacts to the local Maven repository.").withRank(ATask)
diff --git a/main/src/main/scala/sbt/coursierint/LMCoursier.scala b/main/src/main/scala/sbt/coursierint/LMCoursier.scala
index 46bfd8b91..685481bf8 100644
--- a/main/src/main/scala/sbt/coursierint/LMCoursier.scala
+++ b/main/src/main/scala/sbt/coursierint/LMCoursier.scala
@@ -153,6 +153,7 @@ object LMCoursier {
val sv = scalaVersion.value
val lockFile = dependencyLockFile.value
val lockFileOpt = if (lockFile.exists()) Some(lockFile) else None
+ val ivyHomeOpt = ivyPaths.value.ivyHome.map(new File(_))
coursierConfiguration(
csrRecursiveResolvers.value,
csrInterProjectDependencies.value.toVector,
@@ -170,7 +171,7 @@ object LMCoursier {
csrLogger.value,
csrCacheDirectory.value,
csrReconciliations.value,
- ivyPaths.value.ivyHome.map(new File(_)),
+ ivyHomeOpt,
CoursierInputsTasks.strictTask.value,
dependencyOverrides.value,
Some(updateConfiguration.value),
diff --git a/main/src/main/scala/sbt/internal/librarymanagement/IvyXml.scala b/main/src/main/scala/sbt/internal/librarymanagement/IvyXml.scala
index 00635addc..4c61b40a5 100644
--- a/main/src/main/scala/sbt/internal/librarymanagement/IvyXml.scala
+++ b/main/src/main/scala/sbt/internal/librarymanagement/IvyXml.scala
@@ -26,7 +26,8 @@ object IvyXml {
private def rawContent(
currentProject: Project,
- shadedConfigOpt: Option[Configuration]
+ shadedConfigOpt: Option[Configuration],
+ bomForcedDeps: Seq[(String, String, String)]
): String = {
// Important: width = Int.MaxValue, so that no tag gets truncated.
@@ -38,7 +39,7 @@ object IvyXml {
val printer = new scala.xml.PrettyPrinter(Int.MaxValue, 2)
"""""" + '\n' +
- printer.format(content(currentProject, shadedConfigOpt))
+ printer.format(content(currentProject, shadedConfigOpt, bomForcedDeps))
}
// These are required for publish to be fine, later on.
@@ -46,8 +47,10 @@ object IvyXml {
currentProject: Project,
shadedConfigOpt: Option[Configuration],
ivySbt: IvySbt,
- log: sbt.util.Logger
+ log: sbt.util.Logger,
+ resolvedDeps: Seq[sbt.librarymanagement.ModuleID]
): Unit = {
+ val bomForcedDeps = resolvedDeps.map(m => (m.organization, m.name, m.revision))
val ivyCacheManager = ivySbt.withIvy(log)(ivy => ivy.getResolutionCacheManager)
@@ -61,7 +64,7 @@ object IvyXml {
val cacheIvyFile = ivyCacheManager.getResolvedIvyFileInCache(ivyModule)
val cacheIvyPropertiesFile = ivyCacheManager.getResolvedIvyPropertiesInCache(ivyModule)
- val content0 = rawContent(currentProject, shadedConfigOpt)
+ val content0 = rawContent(currentProject, shadedConfigOpt, bomForcedDeps)
cacheIvyFile.getParentFile.mkdirs()
log.debug(s"writing Ivy file $cacheIvyFile")
Files.write(cacheIvyFile.toPath, content0.getBytes(UTF_8))
@@ -72,7 +75,11 @@ object IvyXml {
()
}
- private def content(project0: Project, shadedConfigOpt: Option[Configuration]): Node = {
+ private def content(
+ project0: Project,
+ shadedConfigOpt: Option[Configuration],
+ bomForcedDeps: Seq[(String, String, String)]
+ ): Node = {
val filterOutDependencies =
shadedConfigOpt.toSet[Configuration].flatMap { shadedConfig =>
@@ -144,6 +151,7 @@ object IvyXml {
n
}
+ val bomForcedSet = bomForcedDeps.toSet
val dependencyElems = project.dependencies.toVector.map { (conf, dep) =>
val classifier = {
val pub = dep.publication
@@ -165,10 +173,18 @@ object IvyXml {
} name="*" type="*" ext="*" conf="" matcher="exact"/>
}
+ val org0 = dep.module.organization.value
+ val name0 = dep.module.name.value
+ val rev0 = dep.version
+ val forced = bomForcedSet((org0, name0, rev0))
+ val forceAttr =
+ if (forced) new scala.xml.UnprefixedAttribute("force", "true", scala.xml.Null)
+ else scala.xml.Null
+
val n =
- ${dep.configuration.value}"}>
+ ${dep.configuration.value}"
+ }>
{classifier}
{excludes}
@@ -178,7 +194,7 @@ object IvyXml {
new PrefixedAttribute("e", k, v, acc)
}
- n % moduleAttrs
+ n % moduleAttrs % forceAttr
}
@@ -200,11 +216,13 @@ object IvyXml {
val publications = csrPublications.value
proj.withPublications(publications)
}
+ val resolved = sbt.Keys.resolvedDependencies.value
IvyXml.writeFiles(
currentProject,
shadedConfigOpt,
sbt.Keys.ivySbt.value,
- sbt.Keys.streams.value.log
+ sbt.Keys.streams.value.log,
+ resolved
)
}
}.value)
diff --git a/sbt-app/src/sbt-test/dependency-management/bom-publish-local/a/src/main/scala/A.scala b/sbt-app/src/sbt-test/dependency-management/bom-publish-local/a/src/main/scala/A.scala
new file mode 100644
index 000000000..69c493db2
--- /dev/null
+++ b/sbt-app/src/sbt-test/dependency-management/bom-publish-local/a/src/main/scala/A.scala
@@ -0,0 +1 @@
+object A
diff --git a/sbt-app/src/sbt-test/dependency-management/bom-publish-local/b/src/main/scala/B.scala b/sbt-app/src/sbt-test/dependency-management/bom-publish-local/b/src/main/scala/B.scala
new file mode 100644
index 000000000..251ef7397
--- /dev/null
+++ b/sbt-app/src/sbt-test/dependency-management/bom-publish-local/b/src/main/scala/B.scala
@@ -0,0 +1 @@
+object B
diff --git a/sbt-app/src/sbt-test/dependency-management/bom-publish-local/build.sbt b/sbt-app/src/sbt-test/dependency-management/bom-publish-local/build.sbt
new file mode 100644
index 000000000..29793fdbd
--- /dev/null
+++ b/sbt-app/src/sbt-test/dependency-management/bom-publish-local/build.sbt
@@ -0,0 +1,38 @@
+// BOM + publishLocal (sbt#4531): a uses BOM + jackson-core "*"; b depends on a.
+// For publishLocal, a's published ivy may still list jackson-core:*; so b also needs the BOM
+// to resolve that transitive * (per eed3si9n: BOM needs to be added to all subprojects).
+// Use `common,` not `common*`—compiler's vararg hint is misleading; sbt accepts Seq here.
+ThisBuild / csrCacheDirectory := (ThisBuild / baseDirectory).value / "coursier-cache"
+ThisBuild / organization := "org.example"
+ThisBuild / version := "1.0"
+ThisBuild / scalaVersion := "2.12.18"
+
+lazy val a = project
+ .settings(
+ common,
+ libraryDependencies += ("com.fasterxml.jackson" % "jackson-bom" % "2.21.0").pomOnly(),
+ libraryDependencies += "com.fasterxml.jackson.core" % "jackson-core" % "*",
+ )
+
+lazy val b = project
+ .settings(
+ common,
+ libraryDependencies += ("com.fasterxml.jackson" % "jackson-bom" % "2.21.0").pomOnly(),
+ libraryDependencies += organization.value %% "a" % version.value,
+ TaskKey[Unit]("checkBomFromA") := {
+ val report = (Compile / updateFull).value
+ val compileConfig = report.configurations.find(_.configuration.name == "compile").getOrElse(
+ sys.error("compile configuration not found")
+ )
+ val jacksonCore = compileConfig.modules.find(_.module.name == "jackson-core").getOrElse(
+ sys.error("jackson-core not found in update report (expected from a's published ivy)")
+ )
+ val expected = "2.21.0"
+ if (jacksonCore.module.revision != expected)
+ sys.error(s"Expected jackson-core $expected from a's BOM-resolved ivy, got ${jacksonCore.module.revision}")
+ },
+ )
+
+lazy val common = Seq(
+ ivyPaths := IvyPaths(baseDirectory.value.toString, Some(((ThisBuild / baseDirectory).value / "ivy" / "cache").toString)),
+)
diff --git a/sbt-app/src/sbt-test/dependency-management/bom-publish-local/test b/sbt-app/src/sbt-test/dependency-management/bom-publish-local/test
new file mode 100644
index 000000000..f78b5bc93
--- /dev/null
+++ b/sbt-app/src/sbt-test/dependency-management/bom-publish-local/test
@@ -0,0 +1,3 @@
+> a/publishLocal
+> b/update
+> b/checkBomFromA
diff --git a/sbt-app/src/sbt-test/dependency-management/bom/build.sbt b/sbt-app/src/sbt-test/dependency-management/bom/build.sbt
new file mode 100644
index 000000000..4b8888d3a
--- /dev/null
+++ b/sbt-app/src/sbt-test/dependency-management/bom/build.sbt
@@ -0,0 +1,22 @@
+ThisBuild / csrCacheDirectory := (ThisBuild / baseDirectory).value / "coursier-cache"
+
+// BOM (Bill of Materials) consumption: .pomOnly() + version "*" (sbt#4531)
+scalaVersion := "2.12.18"
+
+libraryDependencies ++= Seq(
+ ("com.fasterxml.jackson" % "jackson-bom" % "2.17.0").pomOnly(),
+ "com.fasterxml.jackson.core" % "jackson-core" % "*"
+)
+
+TaskKey[Unit]("checkBomResolved") := {
+ val report = (Compile / updateFull).value
+ val compileConfig = report.configurations.find(_.configuration.name == "compile").getOrElse(
+ sys.error("compile configuration not found")
+ )
+ val jacksonCoreReport = compileConfig.modules.find(_.module.name == "jackson-core").getOrElse(
+ sys.error("jackson-core not found in update report")
+ )
+ val expectedVersion = "2.17.0"
+ if (jacksonCoreReport.module.revision != expectedVersion)
+ sys.error(s"Expected jackson-core version $expectedVersion from BOM, got ${jacksonCoreReport.module.revision}")
+}
diff --git a/sbt-app/src/sbt-test/dependency-management/bom/test b/sbt-app/src/sbt-test/dependency-management/bom/test
new file mode 100644
index 000000000..5e1b5cb61
--- /dev/null
+++ b/sbt-app/src/sbt-test/dependency-management/bom/test
@@ -0,0 +1,2 @@
+> update
+> checkBomResolved
diff --git a/sbt-app/src/sbt-test/dependency-management/make-pom-type/build.sbt b/sbt-app/src/sbt-test/dependency-management/make-pom-type/build.sbt
index f216d47d8..664981e17 100644
--- a/sbt-app/src/sbt-test/dependency-management/make-pom-type/build.sbt
+++ b/sbt-app/src/sbt-test/dependency-management/make-pom-type/build.sbt
@@ -1,6 +1,6 @@
lazy val p1 = (project in file("p1")).
settings(
- checkTask(expectedMongo),
+ checkTask(expectedMongo, fromDependencyManagement = true),
libraryDependencies += ("org.mongodb" %% "casbah" % "2.4.1").pomOnly(),
inThisBuild(List(
organization := "org.example",
@@ -16,12 +16,14 @@ lazy val p2 = (project in file("p2")).
checkTask(expectedInter)
)
+// BOM (sbt#4531): .pomOnly() deps are emitted under with type=pom, scope=import
lazy val expectedMongo =
org.mongodb
casbah_2.9.2
2.4.1
pom
+ import
lazy val expectedInter =
@@ -31,15 +33,15 @@ lazy val expectedInter =
1.0
-def checkTask(expectedDep: xml.Elem) = TaskKey[Unit]("checkPom") := {
+def checkTask(expectedDep: xml.Elem, fromDependencyManagement: Boolean = false) = TaskKey[Unit]("checkPom") := {
val vf = makePom.value
val converter = fileConverter.value
val pom = xml.XML.loadFile(converter.toPath(vf).toFile)
- val actual = pom \\ "dependencies"
+ val actual = if (fromDependencyManagement) pom \ "dependencyManagement" \ "dependencies" else pom \ "dependencies"
val expected =
{expectedDep}
- def dropTopElem(s:String): String = s.split("""\n""").drop(1).dropRight(1).mkString("\n")
+ def dropTopElem(s: String): String = s.split("""\n""").drop(1).dropRight(1).mkString("\n")
val pp = new xml.PrettyPrinter(Int.MaxValue, 0)
val expectedString = dropTopElem(pp.format(expected))
val actualString = dropTopElem(pp.formatNodes(actual))