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.
This commit is contained in:
Eugene Yokota 2014-07-27 12:14:30 -04:00
parent 3fe8f41a69
commit 439f8498b6
2 changed files with 269 additions and 0 deletions

View File

@ -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
}

View File

@ -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
}