diff --git a/.gitignore b/.gitignore index e762de7f9..f0b892d43 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ target/ __pycache__ +tmp/ diff --git a/ivy/src/main/scala/sbt/CustomPomParser.scala b/ivy/src/main/scala/sbt/CustomPomParser.scala index be98bfe25..e7d721007 100644 --- a/ivy/src/main/scala/sbt/CustomPomParser.scala +++ b/ivy/src/main/scala/sbt/CustomPomParser.scala @@ -174,6 +174,17 @@ object CustomPomParser { def isIdentity = false } + // TODO: It would be better if we can make dd.isForce to `false` when VersionRange.isVersionRange is `true`. + private[this] def stripVersionRange(dd: DependencyDescriptor): DependencyDescriptor = + VersionRange.stripMavenVersionRange(dd.getDependencyRevisionId.getRevision) match { + case Some(newVersion) => + val id = dd.getDependencyRevisionId + val newId = ModuleRevisionId.newInstance(id.getOrganisation, id.getName, id.getBranch, newVersion, id.getExtraAttributes) + transform(dd, _ => newId) + case None => dd + } + private[sbt] lazy val versionRangeFlag = sys.props.get("sbt.modversionrange") map { _.toLowerCase == "true" } getOrElse true + import collection.JavaConverters._ def addExtra(properties: Map[String, String], dependencyExtra: Map[ModuleRevisionId, Map[String, String]], parser: ModuleDescriptorParser, md: ModuleDescriptor): ModuleDescriptor = { @@ -199,7 +210,10 @@ object CustomPomParser { IvySbt.addExtraNamespace(dmd) val withExtra = md.getDependencies map { dd => addExtra(dd, dependencyExtra) } - val unique = IvySbt.mergeDuplicateDefinitions(withExtra) + val withVersionRangeMod: Seq[DependencyDescriptor] = + if (versionRangeFlag) withExtra map { stripVersionRange } + else withExtra + val unique = IvySbt.mergeDuplicateDefinitions(withVersionRangeMod) unique foreach dmd.addDependency for (ed <- md.getInheritedDescriptors) dmd.addInheritedDescriptor(new DefaultExtendsDescriptor(md, ed.getLocation, ed.getExtendsTypes)) diff --git a/ivy/src/main/scala/sbt/MakePom.scala b/ivy/src/main/scala/sbt/MakePom.scala index 742b7b8b4..48851c261 100644 --- a/ivy/src/main/scala/sbt/MakePom.scala +++ b/ivy/src/main/scala/sbt/MakePom.scala @@ -23,56 +23,10 @@ import org.apache.ivy.plugins.resolver.{ ChainResolver, DependencyResolver, IBib import ivyint.CustomRemoteMavenResolver object MakePom { /** True if the revision is an ivy-range, not a complete revision. */ - def isDependencyVersionRange(revision: String): Boolean = { - (revision endsWith "+") || - (revision contains "[") || - (revision contains "]") || - (revision contains "(") || - (revision contains ")") - } + def isDependencyVersionRange(revision: String): Boolean = VersionRange.isVersionRange(revision) /** Converts Ivy revision ranges to that of Maven POM */ - def makeDependencyVersion(revision: String): String = { - def plusRange(s: String, shift: Int = 0) = { - def pow(i: Int): Int = if (i > 0) 10 * pow(i - 1) else 1 - val (prefixVersion, lastVersion) = (s + "0" * shift).reverse.split("\\.", 2) match { - case Array(revLast, revRest) => - (revRest.reverse + ".", revLast.reverse) - case Array(revLast) => ("", revLast.reverse) - } - val lastVersionInt = lastVersion.toInt - s"[${prefixVersion}${lastVersion},${prefixVersion}${lastVersionInt + pow(shift)})" - } - val startSym = Set(']', '[', '(') - val stopSym = Set(']', '[', ')') - val DotPlusPattern = """(.+)\.\+""".r - val DotNumPlusPattern = """(.+)\.(\d+)\+""".r - val NumPlusPattern = """(\d+)\+""".r - val maxDigit = 5 - try { - revision match { - case "+" => "[0,)" - case DotPlusPattern(base) => plusRange(base) - // This is a heuristic. Maven just doesn't support Ivy's notions of 1+, so - // we assume version ranges never go beyond 5 siginificant digits. - case NumPlusPattern(tail) => (0 until maxDigit).map(plusRange(tail, _)).mkString(",") - case DotNumPlusPattern(base, tail) => (0 until maxDigit).map(plusRange(base + "." + tail, _)).mkString(",") - case rev if rev endsWith "+" => sys.error(s"dynamic revision '$rev' cannot be translated to POM") - case rev if startSym(rev(0)) && stopSym(rev(rev.length - 1)) => - val start = rev(0) - val stop = rev(rev.length - 1) - val mid = rev.substring(1, rev.length - 1) - (if (start == ']') "(" else start) + mid + (if (stop == '[') ")" else stop) - case _ => revision - } - } catch { - case e: NumberFormatException => - // TODO - if the version doesn't meet our expectations, maybe we just issue a hard - // error instead of softly ignoring the attempt to rewrite. - //sys.error(s"Could not fix version [$revision] into maven style version") - revision - } - } + def makeDependencyVersion(revision: String): String = VersionRange.fromIvyToMavenVersion(revision) } class MakePom(val log: Logger) { import MakePom._ diff --git a/ivy/src/main/scala/sbt/VersionRange.scala b/ivy/src/main/scala/sbt/VersionRange.scala new file mode 100644 index 000000000..b37e76c62 --- /dev/null +++ b/ivy/src/main/scala/sbt/VersionRange.scala @@ -0,0 +1,81 @@ +package sbt + +object VersionRange { + /** True if the revision is an ivy-range, not a complete revision. */ + def isVersionRange(revision: String): Boolean = { + (revision endsWith "+") || + (revision contains "[") || + (revision contains "]") || + (revision contains "(") || + (revision contains ")") + } + + // Assuming Ivy is used to resolve conflict, this removes the version range + // when it is open-ended to avoid dependency resolution hitting the Internet to get the latest. + // See https://github.com/sbt/sbt/issues/2954 + def stripMavenVersionRange(version: String): Option[String] = + if (isVersionRange(version)) { + val noSpace = version.replaceAllLiterally(" ", "") + noSpace match { + case MavenVersionSetPattern(open1, x1, comma, x2, close1, rest) => + // http://maven.apache.org/components/enforcer/enforcer-rules/versionRanges.html + (open1, Option(x1), Option(comma), Option(x2), close1) match { + case (_, None, _, Some(x2), "]") => Some(x2) + // a good upper bound is unknown + case (_, None, _, Some(x2), ")") => None + case (_, Some(x1), _, None, _) => Some(x1) + case _ => None + } + case _ => None + } + } else None + + /** Converts Ivy revision ranges to that of Maven POM */ + def fromIvyToMavenVersion(revision: String): String = { + def plusRange(s: String, shift: Int = 0) = { + def pow(i: Int): Int = if (i > 0) 10 * pow(i - 1) else 1 + val (prefixVersion, lastVersion) = (s + "0" * shift).reverse.split("\\.", 2) match { + case Array(revLast, revRest) => + (revRest.reverse + ".", revLast.reverse) + case Array(revLast) => ("", revLast.reverse) + } + val lastVersionInt = lastVersion.toInt + s"[${prefixVersion}${lastVersion},${prefixVersion}${lastVersionInt + pow(shift)})" + } + val DotPlusPattern = """(.+)\.\+""".r + val DotNumPlusPattern = """(.+)\.(\d+)\+""".r + val NumPlusPattern = """(\d+)\+""".r + val maxDigit = 5 + try { + revision match { + case "+" => "[0,)" + case DotPlusPattern(base) => plusRange(base) + // This is a heuristic. Maven just doesn't support Ivy's notions of 1+, so + // we assume version ranges never go beyond 5 siginificant digits. + case NumPlusPattern(tail) => (0 until maxDigit).map(plusRange(tail, _)).mkString(",") + case DotNumPlusPattern(base, tail) => (0 until maxDigit).map(plusRange(base + "." + tail, _)).mkString(",") + case rev if rev endsWith "+" => sys.error(s"dynamic revision '$rev' cannot be translated to POM") + case rev if startSym(rev(0)) && stopSym(rev(rev.length - 1)) => + val start = rev(0) + val stop = rev(rev.length - 1) + val mid = rev.substring(1, rev.length - 1) + (if (start == ']') "(" else start) + mid + (if (stop == '[') ")" else stop) + case _ => revision + } + } catch { + case e: NumberFormatException => + // TODO - if the version doesn't meet our expectations, maybe we just issue a hard + // error instead of softly ignoring the attempt to rewrite. + //sys.error(s"Could not fix version [$revision] into maven style version") + revision + } + } + + def hasMavenVersionRange(version: String): Boolean = + if (version.length <= 1) false + else startSym(version(0)) && stopSym(version(version.length - 1)) + + private[this] val startSym = Set(']', '[', '(') + private[this] val stopSym = Set(']', '[', ')') + private[this] val MavenVersionSetPattern = """([\]\[\(])([\w\.\-]+)?(,)?([\w\.\-]+)?([\]\[\)])(,.+)?""".r +} diff --git a/ivy/src/test/scala/VersionRangeSpec.scala b/ivy/src/test/scala/VersionRangeSpec.scala new file mode 100644 index 000000000..8377fc786 --- /dev/null +++ b/ivy/src/test/scala/VersionRangeSpec.scala @@ -0,0 +1,34 @@ +package sbt + +import org.specs2._ + +class VersionRangeSpec extends Specification { + def is = s2""" + + This is a specification to check the version range parsing. + + 1.0 should + ${stripTo("1.0", None)} + (,1.0] should + ${stripTo("(,1.0]", Some("1.0"))} + (,1.0) should + ${stripTo("(,1.0)", None)} + [1.0] should + ${stripTo("[1.0]", Some("1.0"))} + [1.0,) should + ${stripTo("[1.0,)", Some("1.0"))} + (1.0,) should + ${stripTo("(1.0,)", Some("1.0"))} + (1.0,2.0) should + ${stripTo("(1.0,2.0)", None)} + [1.0,2.0] should + ${stripTo("[1.0,2.0]", None)} + (,1.0],[1.2,) should + ${stripTo("(,1.0],[1.2,)", Some("1.0"))} + (,1.1),(1.1,) should + ${stripTo("(,1.1),(1.1,)", None)} + """ + + def stripTo(s: String, expected: Option[String]) = + VersionRange.stripMavenVersionRange(s) must_== expected +} diff --git a/notes/0.13.14/mavenversionrange.md b/notes/0.13.14/mavenversionrange.md new file mode 100644 index 000000000..ec61123d4 --- /dev/null +++ b/notes/0.13.14/mavenversionrange.md @@ -0,0 +1,22 @@ + +### Fixes with compatibility implications + +- sbt 0.13.14 removes the Maven version range when possible. See below. + +### Maven version range improvement + +Previously, when the dependency resolver (Ivy) encountered a Maven version range such as `[1.3.0,)` +it would go out to the Internet to find the latest version. +This would result to a surprising behavior where the eventual version keeps changing over time +*even when there's a version of the library that satisfies the range condition*. + +Starting sbt 0.13.14, some Maven version ranges would be replaced with its lower bound +so that when a satisfactory version is found in the dependency graph it will be used. +You can disable this behavior using the JVM flag `-Dsbt.modversionrange=false`. + +[#2954][2954] by [@eed3si9n][@eed3si9n] + + [@eed3si9n]: https://github.com/eed3si9n + [@dwijnand]: https://github.com/dwijnand + [@Duhemm]: https://github.com/Duhemm + [2954]: https://github.com/sbt/sbt/issues/2954 diff --git a/sbt/src/sbt-test/dependency-management/dynamic-revision/build.sbt b/sbt/src/sbt-test/dependency-management/dynamic-revision/build.sbt index 53db09db6..35901facc 100644 --- a/sbt/src/sbt-test/dependency-management/dynamic-revision/build.sbt +++ b/sbt/src/sbt-test/dependency-management/dynamic-revision/build.sbt @@ -1,5 +1,23 @@ +lazy val check = taskKey[Unit]("Runs the check") + +def commonSettings: Seq[Def.Setting[_]] = + Seq( + ivyPaths := new IvyPaths( (baseDirectory in ThisBuild).value, Some((target in LocalRootProject).value / "ivy-cache")), + scalaVersion := "2.10.6" + ) + lazy val root = (project in file(".")). settings( - libraryDependencies += "org.webjars" %% "webjars-play" % "2.1.0-3", - resolvers += Resolver.typesafeRepo("releases") + commonSettings, + libraryDependencies ++= Seq( + "org.webjars.bower" % "angular" % "1.4.7", + "org.webjars.bower" % "angular-bootstrap" % "0.14.2" + ), + resolvers += Resolver.typesafeRepo("releases"), + check := { + val acp = (externalDependencyClasspath in Compile).value.map {_.data.getName}.sorted + if (!(acp contains "angular-1.4.7.jar")) { + sys.error("angular-1.4.7.jar not found when it should be included: " + acp.toString) + } + } ) diff --git a/sbt/src/sbt-test/dependency-management/dynamic-revision/test b/sbt/src/sbt-test/dependency-management/dynamic-revision/test index 103bd8d2f..762c62539 100644 --- a/sbt/src/sbt-test/dependency-management/dynamic-revision/test +++ b/sbt/src/sbt-test/dependency-management/dynamic-revision/test @@ -1 +1,2 @@ > update +> check