diff --git a/ivy/src/main/scala/sbt/VersionNumber.scala b/ivy/src/main/scala/sbt/VersionNumber.scala new file mode 100644 index 000000000..7db8c8fef --- /dev/null +++ b/ivy/src/main/scala/sbt/VersionNumber.scala @@ -0,0 +1,147 @@ +package sbt + +final class VersionNumber private[sbt] ( + val numbers: Seq[Long], + val tags: Seq[String], + val extras: Seq[String]) { + def _1: Option[Long] = get(0) + def _2: Option[Long] = get(1) + def _3: Option[Long] = get(2) + def _4: Option[Long] = get(3) + def get(idx: Int): Option[Long] = + if (size <= idx) None + else Some(numbers(idx)) + def size: Int = numbers.size + + private[this] val versionStr: String = + numbers.mkString(".") + + (tags match { + case Seq() => "" + case ts => "-" + ts.mkString("-") + }) + + extras.mkString("") + override def toString: String = versionStr + override def hashCode: Int = + numbers.hashCode * 41 * 41 + + tags.hashCode * 41 + + extras.hashCode + override def equals(o: Any): Boolean = + o match { + case v: VersionNumber => (this.numbers == v.numbers) && (this.tags == v.tags) && (this.extras == v.extras) + case _ => false + } +} + +object VersionNumber { + /** + * @param numbers numbers delimited by a dot. + * @param tags string prefixed by a dash. + * @param any other strings at the end. + */ + def apply(numbers: Seq[Long], tags: Seq[String], extras: Seq[String]): VersionNumber = + new VersionNumber(numbers, tags, extras) + def apply(v: String): VersionNumber = + unapply(v) match { + case Some((ns, ts, es)) => VersionNumber(ns, ts, es) + case _ => sys.error(s"Invalid version number: $v") + } + + def unapply(v: VersionNumber): Option[(Seq[Long], Seq[String], Seq[String])] = + Some((v.numbers, v.tags, v.extras)) + + def unapply(v: String): Option[(Seq[Long], Seq[String], Seq[String])] = { + def splitDot(s: String): Vector[Long] = + Option(s) match { + case Some(x) => x.split('.').toVector.filterNot(_ == "").map(_.toLong) + case _ => Vector() + } + def splitDash(s: String): Vector[String] = + Option(s) match { + case Some(x) => x.split('-').toVector.filterNot(_ == "") + case _ => Vector() + } + def splitPlus(s: String): Vector[String] = + Option(s) match { + case Some(x) => x.split('+').toVector.filterNot(_ == "").map("+" + _) + case _ => Vector() + } + val TaggedVersion = """(\d{1,14})([\.\d{1,14}]*)((?:-\w+)*)((?:\+.+)*)""".r + val NonSpaceString = """(\S+)""".r + v match { + case TaggedVersion(m, ns, ts, es) => Some((Vector(m.toLong) ++ splitDot(ns), splitDash(ts), splitPlus(es))) + case "" => None + case NonSpaceString(s) => Some((Vector(), Vector(), Vector(s))) + case _ => None + } + } + + /** + * Strict. Checks everythig. + */ + object Strict extends VersionNumberCompatibility { + def name: String = "Strict" + def isCompatible(v1: VersionNumber, v2: VersionNumber): Boolean = v1 == v2 + } + + /** + * Semantic versioning. See http://semver.org/spec/v2.0.0.html + */ + object SemVer extends VersionNumberCompatibility { + def name: String = "Semantic Versioning" + def isCompatible(v1: VersionNumber, v2: VersionNumber): Boolean = + doIsCompat(v1, v2) || doIsCompat(v2, v1) + private[this] def doIsCompat(v1: VersionNumber, v2: VersionNumber): Boolean = + (v1, v2) match { + case (v1, v2) if (v1.size >= 2) && (v2.size >= 2) => // A normal version number MUST take the form X.Y.Z + (v1._1.get, v1._2.get, v1._3.getOrElse(0), v1.tags, v2._1.get, v2._2.get, v2._3.getOrElse(0), v2.tags) match { + case (0L, _, _, _, 0L, _, _, _) => + // Major version zero (0.y.z) is for initial development. Anything may change at any time. The public API should not be considered stable. + equalsIgnoreExtra(v1, v2) + case (_, 0, 0, ts1, _, 0, 0, ts2) if (!ts1.isEmpty) || (!ts2.isEmpty) => + // A pre-release version MAY be denoted by appending a hyphen and a series of dot separated identifiers + equalsIgnoreExtra(v1, v2) + case (x1, _, _, _, x2, _, _, _) => + // Patch version Z (x.y.Z | x > 0) MUST be incremented if only backwards compatible bug fixes are introduced. + // Minor version Y (x.Y.z | x > 0) MUST be incremented if new, backwards compatible functionality is introduced + x1 == x2 + case _ => equalsIgnoreExtra(v1, v2) + } + case _ => false + } + // Build metadata SHOULD be ignored when determining version precedence. + private[this] def equalsIgnoreExtra(v1: VersionNumber, v2: VersionNumber): Boolean = + (v1.numbers == v2.numbers) && (v1.tags == v2.tags) + } + + /* A variant of SemVar that seems to be common among the Scala libraries. + * The second segment (y in x.y.z) increments breaks the binary compatibility even when x > 0. + * Also API comatibility is expected even when the first segment is zero. + */ + object SecondSegment extends VersionNumberCompatibility { + def name: String = "Second Segment Variant" + def isCompatible(v1: VersionNumber, v2: VersionNumber): Boolean = + doIsCompat(v1, v2) || doIsCompat(v2, v1) + private[this] def doIsCompat(v1: VersionNumber, v2: VersionNumber): Boolean = + (v1, v2) match { + case (v1, v2) if (v1.size >= 3) && (v2.size >= 3) => // A normal version number MUST take the form X.Y.Z + (v1._1.get, v1._2.get, v1._3.get, v1.tags, v2._1.get, v2._2.get, v2._3.get, v2.tags) match { + case (x1, y1, 0, ts1, x2, y2, 0, ts2) if (!ts1.isEmpty) || (!ts2.isEmpty) => + // A pre-release version MAY be denoted by appending a hyphen and a series of dot separated identifiers + equalsIgnoreExtra(v1, v2) + case (x1, y1, _, _, x2, y2, _, _) => + // Patch version Z (x.y.Z | x > 0) MUST be incremented if only backwards compatible changes are introduced. + (x1 == x2) && (y1 == y2) + case _ => equalsIgnoreExtra(v1, v2) + } + case _ => false + } + // Build metadata SHOULD be ignored when determining version precedence. + private[this] def equalsIgnoreExtra(v1: VersionNumber, v2: VersionNumber): Boolean = + (v1.numbers == v2.numbers) && (v1.tags == v2.tags) + } +} + +trait VersionNumberCompatibility { + def name: String + def isCompatible(v1: VersionNumber, v2: VersionNumber): Boolean +} diff --git a/ivy/src/test/scala/VersionNumberSpec.scala b/ivy/src/test/scala/VersionNumberSpec.scala new file mode 100644 index 000000000..7b836050f --- /dev/null +++ b/ivy/src/test/scala/VersionNumberSpec.scala @@ -0,0 +1,122 @@ +package sbt + +import org.specs2._ + +class VersionNumberSpec extends Specification { + def is = s2""" + + This is a specification to check the version number parsing. + + 1 should + ${beParsedAs("1", Seq(1), Seq(), Seq())} + ${breakDownTo("1", Some(1))} + + 1.0 should + ${beParsedAs("1.0", Seq(1, 0), Seq(), Seq())} + ${breakDownTo("1.0", Some(1), Some(0))} + + 1.0.0 should + ${beParsedAs("1.0.0", Seq(1, 0, 0), Seq(), Seq())} + ${breakDownTo("1.0.0", Some(1), Some(0), Some(0))} + + ${beSemVerCompatWith("1.0.0", "1.0.1")} + ${beSemVerCompatWith("1.0.0", "1.1.1")} + ${notBeSemVerCompatWith("1.0.0", "2.0.0")} + ${notBeSemVerCompatWith("1.0.0", "1.0.0-M1")} + + ${beSecSegCompatWith("1.0.0", "1.0.1")} + ${notBeSecSegCompatWith("1.0.0", "1.1.1")} + ${notBeSecSegCompatWith("1.0.0", "2.0.0")} + ${notBeSecSegCompatWith("1.0.0", "1.0.0-M1")} + + 1.0.0.0 should + ${beParsedAs("1.0.0.0", Seq(1, 0, 0, 0), Seq(), Seq())} + ${breakDownTo("1.0.0.0", Some(1), Some(0), Some(0), Some(0))} + + 0.12.0 should + ${beParsedAs("0.12.0", Seq(0, 12, 0), Seq(), Seq())} + ${breakDownTo("0.12.0", Some(0), Some(12), Some(0))} + + ${notBeSemVerCompatWith("0.12.0", "0.12.0-RC1")} + ${notBeSemVerCompatWith("0.12.0", "0.12.1")} + ${notBeSemVerCompatWith("0.12.0", "0.12.1-M1")} + + ${notBeSecSegCompatWith("0.12.0", "0.12.0-RC1")} + ${beSecSegCompatWith("0.12.0", "0.12.1")} + ${beSecSegCompatWith("0.12.0", "0.12.1-M1")} + + 0.1.0-SNAPSHOT should + ${beParsedAs("0.1.0-SNAPSHOT", Seq(0, 1, 0), Seq("SNAPSHOT"), Seq())} + + ${beSemVerCompatWith("0.1.0-SNAPSHOT", "0.1.0-SNAPSHOT")} + ${notBeSemVerCompatWith("0.1.0-SNAPSHOT", "0.1.0")} + ${beSemVerCompatWith("0.1.0-SNAPSHOT", "0.1.0-SNAPSHOT+001")} + + ${beSecSegCompatWith("0.1.0-SNAPSHOT", "0.1.0-SNAPSHOT")} + ${notBeSecSegCompatWith("0.1.0-SNAPSHOT", "0.1.0")} + ${beSecSegCompatWith("0.1.0-SNAPSHOT", "0.1.0-SNAPSHOT+001")} + + 0.1.0-M1 should + ${beParsedAs("0.1.0-M1", Seq(0, 1, 0), Seq("M1"), Seq())} + + 0.1.0-RC1 should + ${beParsedAs("0.1.0-RC1", Seq(0, 1, 0), Seq("RC1"), Seq())} + + 0.1.0-MSERVER-1 should + ${beParsedAs("0.1.0-MSERVER-1", Seq(0, 1, 0), Seq("MSERVER", "1"), Seq())} + + 2.10.4-20140115-000117-b3a-sources should + ${beParsedAs("2.10.4-20140115-000117-b3a-sources", Seq(2, 10, 4), Seq("20140115", "000117", "b3a", "sources"), Seq())} + + ${beSemVerCompatWith("2.10.4-20140115-000117-b3a-sources", "2.0.0")} + + ${notBeSecSegCompatWith("2.10.4-20140115-000117-b3a-sources", "2.0.0")} + + 20140115000117-b3a-sources should + ${beParsedAs("20140115000117-b3a-sources", Seq(20140115000117L), Seq("b3a", "sources"), Seq())} + + 1.0.0-alpha+001+002 should + ${beParsedAs("1.0.0-alpha+001+002", Seq(1, 0, 0), Seq("alpha"), Seq("+001", "+002"))} + + non.space.!?string should + ${beParsedAs("non.space.!?string", Seq(), Seq(), Seq("non.space.!?string"))} + + space !?string should + ${beParsedAsError("space !?string")} + + blank string should + ${beParsedAsError("")} + """ + + def beParsedAs(s: String, ns: Seq[Long], ts: Seq[String], es: Seq[String]) = + s match { + case VersionNumber(ns1, ts1, es1) if (ns1 == ns && ts1 == ts && es1 == es) => + (VersionNumber(ns, ts, es).toString must_== s) and + (VersionNumber(ns, ts, es) == VersionNumber(ns, ts, es)) + case VersionNumber(ns1, ts1, es1) => + sys.error(s"$ns1, $ts1, $es1") + } + def breakDownTo(s: String, major: Option[Long], minor: Option[Long] = None, + patch: Option[Long] = None, buildNumber: Option[Long] = None) = + s match { + case VersionNumber(ns, ts, es) => + val v = VersionNumber(ns, ts, es) + (v._1 must_== major) and + (v._2 must_== minor) and + (v._3 must_== patch) and + (v._4 must_== buildNumber) + } + def beParsedAsError(s: String) = + s match { + case VersionNumber(ns1, ts1, es1) => failure + case _ => success + } + def beSemVerCompatWith(v1: String, v2: String) = + VersionNumber.SemVer.isCompatible(VersionNumber(v1), VersionNumber(v2)) must_== true + def notBeSemVerCompatWith(v1: String, v2: String) = + VersionNumber.SemVer.isCompatible(VersionNumber(v1), VersionNumber(v2)) must_== false + def beSecSegCompatWith(v1: String, v2: String) = + VersionNumber.SecondSegment.isCompatible(VersionNumber(v1), VersionNumber(v2)) must_== true + def notBeSecSegCompatWith(v1: String, v2: String) = + VersionNumber.SecondSegment.isCompatible(VersionNumber(v1), VersionNumber(v2)) must_== false +}