From 439f8498b67abc21e1e2adad9c0550e57d65e74c Mon Sep 17 00:00:00 2001 From: Eugene Yokota Date: Sun, 27 Jul 2014 12:14:30 -0400 Subject: [PATCH] pseudo-case class VersionNumber VersionNumber is a pseudo-case class that represents any form of version number. The unapply extractor can parse String into three sequences that makes up VersionNumber. VersionNumberCompatibility trait uses two VersionNumber instances to evaluate binary compatibility between them. Two implementations SemVer and SecondSegment are provided. --- ivy/src/main/scala/sbt/VersionNumber.scala | 147 +++++++++++++++++++++ ivy/src/test/scala/VersionNumberSpec.scala | 122 +++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 ivy/src/main/scala/sbt/VersionNumber.scala create mode 100644 ivy/src/test/scala/VersionNumberSpec.scala 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 +}