From 0f93849214f8409d785f552ae8d5e4e086c33c8f Mon Sep 17 00:00:00 2001 From: Dale Wijnand Date: Thu, 15 Mar 2018 01:45:45 +0000 Subject: [PATCH] Cleanup & tweak VersionNumber --- .../sbt/librarymanagement/VersionNumber.scala | 229 ++++++++++-------- .../librarymanagement/VersionNumberSpec.scala | 4 +- 2 files changed, 131 insertions(+), 102 deletions(-) diff --git a/core/src/main/scala/sbt/librarymanagement/VersionNumber.scala b/core/src/main/scala/sbt/librarymanagement/VersionNumber.scala index f6a95527c..fa4f1f35f 100644 --- a/core/src/main/scala/sbt/librarymanagement/VersionNumber.scala +++ b/core/src/main/scala/sbt/librarymanagement/VersionNumber.scala @@ -5,38 +5,31 @@ final class VersionNumber private[sbt] ( 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 get(idx: Int): Option[Long] = numbers lift idx def size: Int = numbers.size /** The vector of version numbers from more to less specific from this version number. */ lazy val cascadingVersions: Vector[VersionNumber] = - (Vector(this) ++ - (numbers.inits filter (_.length >= 2) map (VersionNumber(_, Nil, Nil)))).distinct + (Vector(this) ++ (numbers.inits filter (_.size >= 2) map (VersionNumber(_, Nil, Nil)))).distinct - 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 - } + override val toString: String = + numbers.mkString(".") + mkString1(tags, "-", "-", "") + extras.mkString("") + + override def hashCode: Int = numbers.## * 41 * 41 + tags.## * 41 + extras.## + + override def equals(that: Any): Boolean = that match { + case v: VersionNumber => (numbers == v.numbers) && (tags == v.tags) && (extras == v.extras) + case _ => false + } + + /** A variant of mkString that returns the empty string if the sequence is empty. */ + private[this] def mkString1[A](xs: Seq[A], start: String, sep: String, end: String): String = + if (xs.isEmpty) "" else xs.mkString(start, sep, end) } object VersionNumber { @@ -44,120 +37,156 @@ object VersionNumber { /** * @param numbers numbers delimited by a dot. * @param tags string prefixed by a dash. - * @param any other strings at the end. + * @param extras 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 { + + def apply(s: String): VersionNumber = + unapply(s) match { case Some((ns, ts, es)) => VersionNumber(ns, ts, es) - case _ => sys.error(s"Invalid version number: $v") + case _ => throw new IllegalArgumentException(s"Invalid version number: $s") } 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() - } + def unapply(s: String): Option[(Seq[Long], Seq[String], Seq[String])] = { + + // null safe, empty string safe + def splitOn[A](s: String, sep: Char): Vector[String] = + if (s eq null) Vector() + else s.split(sep).filterNot(_ == "").toVector + + def splitDot(s: String) = splitOn(s, '.') map (_.toLong) + def splitDash(s: String) = splitOn(s, '-') + def splitPlus(s: String) = splitOn(s, '+') map ("+" + _) + val TaggedVersion = """(\d{1,14})([\.\d{1,14}]*)((?:-\w+)*)((?:\+.+)*)""".r val NonSpaceString = """(\S+)""".r - v match { + + s match { case TaggedVersion(m, ns, ts, es) => - Some((Vector(m.toLong) ++ splitDot(ns), splitDash(ts), splitPlus(es))) + val numbers = Vector(m.toLong) ++ splitDot(ns) + val tags = splitDash(ts) + val extras = splitPlus(es) + Some((numbers, tags, extras)) case "" => None - case NonSpaceString(s) => Some((Vector(), Vector(), Vector(s))) + case NonSpaceString(s) => Some((Vector.empty, Vector.empty, Vector(s))) case _ => None } } - /** - * Strict. Checks everythig. - */ + /** Strict. Checks everything. */ 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 - */ + /** Semantic Versioning. See http://semver.org/spec/v2.0.0.html */ object SemVer extends VersionNumberCompatibility { def name: String = "Semantic Versioning" + + /* Quotes of parts of the rules in the SemVer Spec relevant to compatibility checking: + * + * Rule 2: + * > A normal version number MUST take the form X.Y.Z + * + * Rule 4: + * > Major version zero (0.y.z) is for initial development. Anything may change at any time. + * + * Rule 6: + * > Patch version Z (x.y.Z | x > 0) MUST be incremented if only backwards compatible bug fixes are introduced. + * + * Rule 7: + * > Minor version Y (x.Y.z | x > 0) MUST be incremented if new, backwards compatible functionality is introduced. + * + * Rule 8: + * > Major version X (X.y.z | X > 0) MUST be incremented if any backwards incompatible changes are introduced. + * + * Rule 9: + * > A pre-release version MAY be denoted by appending a hyphen and a series of + * > dot separated identifiers immediately following the patch version. + * > Identifiers MUST comprise only ASCII alphanumerics and hyphen [0-9A-Za-z-]. + * > Identifiers MUST NOT be empty. + * > Numeric identifiers MUST NOT include leading zeroes. + * > Pre-release versions have a lower precedence than the associated normal version. + * > A pre-release version indicates that the version is unstable and might not satisfy the + * > intended compatibility requirements as denoted by its associated normal version. + * > Examples: 1.0.0-alpha, 1.0.0-alpha.1, 1.0.0-0.3.7, 1.0.0-x.7.z.92. + * + * Rule 10: + * > Build metadata MAY be denoted by appending a plus sign and a series of + * > dot separated identifiers immediately following the patch or pre-release version. + * > Identifiers MUST comprise only ASCII alphanumerics and hyphen [0-9A-Za-z-]. + * > Identifiers MUST NOT be empty. + * > Build metadata SHOULD be ignored when determining version precedence. + * > Thus two versions that differ only in the build metadata, have the same precedence. + * > Examples: 1.0.0-alpha+001, 1.0.0+20130313144700, 1.0.0-beta+exp.sha.5114f85. + * + * Rule 10 means that build metadata is never considered for compatibility + * we'll enforce this immediately by dropping them from both versions + * Rule 2 we enforce with custom extractors. + * Rule 4 we enforce by matching x = 0 & fully equals checking the two versions + * Rule 6, 7 & 8 means version compatibility is determined by comparing the two X values + * Rule 9 means pre-release versions are fully equals checked + */ def isCompatible(v1: VersionNumber, v2: VersionNumber): Boolean = - doIsCompat(v1, v2) || doIsCompat(v2, v1) + doIsCompat(dropBuildMetadata(v1), dropBuildMetadata(v2)) + 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(0L), - v1.tags, - v2._1.get, - v2._2.get, - v2._3.getOrElse(0L), - 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.nonEmpty || ts2.nonEmpty => - // 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 + case (NormalVersion(0, _, _), NormalVersion(0, _, _)) => v1 == v2 // R4 + case (NormalVersion(x1, _, _), NormalVersion(x2, _, _)) => x1 == x2 // R6, R7 & R8 + case (PrereleaseVersion(_, _, _), PrereleaseVersion(_, _, _)) => v1 == v2 // R9 + 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) + + // SemVer Spec Rule 10 (above) + private[VersionNumber] def dropBuildMetadata(v: VersionNumber) = + if (v.extras.isEmpty) v else VersionNumber(v.numbers, v.tags, Nil) + + // SemVer Spec Rule 9 (above) + private[VersionNumber] def isPrerelease(v: VersionNumber): Boolean = v.tags.nonEmpty + + // An extractor for SemVer's "normal version number" - SemVer Spec Rule 2 & Rule 9 (above) + private[VersionNumber] object NormalVersion { + def unapply(v: VersionNumber): Option[(Long, Long, Long)] = + PartialFunction.condOpt(v.numbers) { + // NOTE! We allow the z to be missing, because of legacy like commons-io 1.3 + case Seq(x, y, _*) if !isPrerelease(v) => (x, y, v._3 getOrElse 0) + } + } + + // An extractor for SemVer's "pre-release versions" - SemVer Spec Rule 2 & Rule 9 (above) + private[VersionNumber] object PrereleaseVersion { + def unapply(v: VersionNumber): Option[(Long, Long, Long)] = + PartialFunction.condOpt(v.numbers) { + // NOTE! We allow the z to be missing, because of legacy like commons-io 1.3 + case Seq(x, y, _*) if isPrerelease(v) => (x, y, v._3 getOrElse 0) + } + } } - /* A variant of SemVar that seems to be common among the Scala libraries. + /** 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. + * Also API compatibility is expected even when the first segment is zero. */ object SecondSegment extends VersionNumberCompatibility { + import SemVer._ + 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 = + doIsCompat(dropBuildMetadata(v1), dropBuildMetadata(v2)) + + 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.nonEmpty || ts2.nonEmpty => - // 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 + case (NormalVersion(x1, y1, _), NormalVersion(x2, y2, _)) => (x1 == x2) && (y1 == y2) + case (PrereleaseVersion(_, _, _), PrereleaseVersion(_, _, _)) => v1 == v2 // R2 & R9 + 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) + } } } diff --git a/core/src/test/scala/sbt/librarymanagement/VersionNumberSpec.scala b/core/src/test/scala/sbt/librarymanagement/VersionNumberSpec.scala index eab52f6c9..dee74c35b 100644 --- a/core/src/test/scala/sbt/librarymanagement/VersionNumberSpec.scala +++ b/core/src/test/scala/sbt/librarymanagement/VersionNumberSpec.scala @@ -51,7 +51,7 @@ class VersionNumberSpec extends FreeSpec with Matchers with Inside { assertIsNotCompatibleWith(v, "0.12.0-RC1", SecondSegment) assertIsCompatibleWith(v, "0.12.1", SecondSegment) - assertIsCompatibleWith(v, "0.12.1-M1", SecondSegment) + assertIsNotCompatibleWith(v, "0.12.1-M1", SecondSegment) } version("0.1.0-SNAPSHOT") { v => @@ -85,7 +85,7 @@ class VersionNumberSpec extends FreeSpec with Matchers with Inside { version("2.10.4-20140115-000117-b3a-sources") { v => assertParsesTo(v, Seq(2, 10, 4), Seq("20140115", "000117", "b3a", "sources"), Seq()) assertCascadesTo(v, Seq("2.10.4-20140115-000117-b3a-sources", "2.10.4", "2.10")) - assertIsCompatibleWith(v, "2.0.0", SemVer) + assertIsNotCompatibleWith(v, "2.0.0", SemVer) assertIsNotCompatibleWith(v, "2.0.0", SecondSegment) }