diff --git a/README.md b/README.md index 4d16323..8380205 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,24 @@ The coursier documentation [details](https://get-coursier.io/docs/other-version- how versions are compared. `Version` implements `Ordered[Version]`, so that `Version` instances can be compared together, -and a sequence of `Version`s can be sorted. +and a sequence of `Version`s can be sorted. That order is total, and consistent with `equals`: +`a.compare(b)` is `0` if and only if `a == b`. Versions that mean the same thing but are spelled +differently, like `1.0` and `1.0.0`, or `1.2+foo` and `1.2+bar`, are ordered by their +representation, so that sorted collections and hash-based ones agree on which versions are +distinct. + +Use `compareSemantic` to compare versions up to that equivalence - it returns `0` for versions +that only differ by padding, separators, build metadata, or a release qualifier like `Final` or +`GA`. This is the order that decides whether a version sits in an interval, or whether two +constraints can be reconciled: +```scala +Version("1.0").compare(Version("1.0.0")) +// res: Int = -2 +Version("1.0").compareSemantic(Version("1.0.0")) +// res: Int = 0 +Version("1.1.0").compareSemantic(Version("1.1.0.Final")) +// res: Int = 0 +``` ## `VersionConstraint` diff --git a/README.template.md b/README.template.md index 19fbefe..6d34d85 100644 --- a/README.template.md +++ b/README.template.md @@ -54,7 +54,24 @@ The coursier documentation [details](https://get-coursier.io/docs/other-version- how versions are compared. `Version` implements `Ordered[Version]`, so that `Version` instances can be compared together, -and a sequence of `Version`s can be sorted. +and a sequence of `Version`s can be sorted. That order is total, and consistent with `equals`: +`a.compare(b)` is `0` if and only if `a == b`. Versions that mean the same thing but are spelled +differently, like `1.0` and `1.0.0`, or `1.2+foo` and `1.2+bar`, are ordered by their +representation, so that sorted collections and hash-based ones agree on which versions are +distinct. + +Use `compareSemantic` to compare versions up to that equivalence - it returns `0` for versions +that only differ by padding, separators, build metadata, or a release qualifier like `Final` or +`GA`. This is the order that decides whether a version sits in an interval, or whether two +constraints can be reconciled: +```scala +Version("1.0").compare(Version("1.0.0")) +// res: Int = -2 +Version("1.0").compareSemantic(Version("1.0.0")) +// res: Int = 0 +Version("1.1.0").compareSemantic(Version("1.1.0.Final")) +// res: Int = 0 +``` ## `VersionConstraint` diff --git a/versions/shared/src/coursier/version/Version.scala b/versions/shared/src/coursier/version/Version.scala index 4ae1845..202cbbf 100644 --- a/versions/shared/src/coursier/version/Version.scala +++ b/versions/shared/src/coursier/version/Version.scala @@ -18,9 +18,48 @@ case class Version(repr: String) extends Ordered[Version] { items0 = Version.items(repr) items0 } - def compare(other: Version) = { + /** + * Total order on versions, consistent with `equals` and `hashCode`. + * + * `a.compare(b) == 0` if and only if `a == b`. Versions that are semantically + * equivalent but spelled differently (`1.0` and `1.0.0`, `1.2` and `1.2+foo`, + * `1.2+bar` and `1.2+foo`, …) are ordered by their representation, so that + * sorted collections and hash-based ones agree on which versions are distinct. + * + * Use [[compareSemantic]] to compare versions up to that equivalence. + */ + def compare(other: Version): Int = { + if (repr == other.repr) 0 // fast path + else { + val cmp = Version.listCompare(items, other.items) + // Break ties so that the order is total: versions that only differ by + // padding, separators, or build metadata are ordered by representation. + if (cmp == 0) repr.compareTo(other.repr) + else cmp + } + } + + /** + * Compares versions up to the equivalence induced by version parsing. + * + * Returns `0` for versions that have the same meaning but different + * representations, like `1.0` and `1.0.0`, `1.1.0` and `1.1.0.Final`, or + * `1.2+foo` and `1.2+bar` (Semver § 10: build metadata doesn't take part in + * precedence). + * + * This is *not* consistent with `equals` - it is the order to use to decide + * whether a version sits in an interval, or whether two versions can be + * reconciled. Use [[compare]] for sorting or for sorted collections. + */ + def compareSemantic(other: Version): Int = if (repr == other.repr) 0 // fast path - else Version.listCompare(items, other.items) + else Version.listCompare(semanticItems, other.semanticItems) + + private var semanticItems0: Vector[Version.Item] = null + private def semanticItems: Vector[Version.Item] = { + if (semanticItems0 == null) + semanticItems0 = Version.semanticItems(items) + semanticItems0 } def isEmpty = items.forall(_.isEmpty) @@ -121,6 +160,16 @@ object Version { override def compareToEmpty = level.compare(0) def isPreRelease: Boolean = level < Tag.emptyLevel + + /** + * Whether this tag stands for the absence of a qualifier, like the `Final` of + * `3.10.1.Final` or the `GA` of `1.2.3.GA`. + * + * [[Version.compare]] orders those after the empty item, so that no two versions compare + * equal, while [[Version.compareSemantic]] treats them as the empty tag. + */ + def isReleaseEquivalent: Boolean = + level == Tag.emptyLevel || level == Tag.gaLevel || level == Tag.finalLevel def compareTag(other: Tag): Int = { val levelComp = level.compare(other.level) if (levelComp == 0 && level == Tag.otherLevel) value.compareToIgnoreCase(other.value) @@ -281,6 +330,26 @@ object Version { first +: tokens.toVector.map(_._2) } + private val emptyTag = Tag("") + + /** + * Replaces the tags that stand for the absence of a qualifier - `ga` and `final` - by the + * empty tag, so that `1.1.0`, `1.1.0.GA` and `1.1.0.Final` compare equal. + * + * Those tags are ordered right after the empty item, and nothing else sits between them, so + * that collapsing them keeps the order of everything else unchanged. + */ + private def semanticItems(items: Vector[Item]): Vector[Item] = { + def isReleaseEquivalent(item: Item): Boolean = + item match { + case t: Tag => t.isReleaseEquivalent + case _ => false + } + if (items.exists(isReleaseEquivalent)) + items.map(item => if (isReleaseEquivalent(item)) emptyTag else item) + else items + } + // before comparing two versions pad the number parts to the equal number of digits // for example, 1-ga, and 1.0.0 comparison will be adjusted first to 1.0.0-ga and 1.0.0. def listCompare(first0: Vector[Item], second0: Vector[Item]): Int = { diff --git a/versions/shared/src/coursier/version/VersionCompatibility.scala b/versions/shared/src/coursier/version/VersionCompatibility.scala index 296a540..7edfdc5 100644 --- a/versions/shared/src/coursier/version/VersionCompatibility.scala +++ b/versions/shared/src/coursier/version/VersionCompatibility.scala @@ -25,6 +25,52 @@ sealed abstract class VersionCompatibility { object VersionCompatibility { + /** + * Whether `version` carries a pre-release qualifier. + * + * Two kinds of items count as pre-releases: + * - pre-release qualifiers, wherever they appear (`1.2.3-RC1`, `2.13.0-M3`, `1.2.3.RC1`), + * - plain literals right after a `-`, which is where semantic versioning puts + * pre-releases (`1.0.0-preview`). + * + * Release qualifiers and plain literals in the fourth, dot-separated segment of a + * Maven-style version are deliberately *not* pre-releases: `3.10.1.Final` and + * `9.4.25.v20191220` are releases. + */ + private def hasPreReleaseQualifier(version: Version): Boolean = { + val (first, rest) = Version.Tokenizer(version.repr) + def preRelease(item: Version.Item, afterHyphen: Boolean): Boolean = + item match { + case t: Version.Tag => + if (t.isQualifier) t.isPreRelease else afterHyphen + case _ => false + } + preRelease(first, afterHyphen = false) || + rest.exists { + case (sep, item) => preRelease(item, sep == Version.Tokenizer.Hyphen) + } + } + + /** + * Whether `version` can take part in a semantic versioning compatibility check as a constraint. + * + * Its significant part (the major number, or major and minor for 0.x versions) must be numeric, + * the rest must be made of numbers, qualifiers and build metadata, and it must not be a + * pre-release - per semantic versioning, a constraint like `1.2.3-RC1` only accepts itself. + */ + private def isSemVerComparable(version: Version, significantPartLength: Int): Boolean = { + val items = version.items + items.lengthCompare(significantPartLength) >= 0 && + items.take(significantPartLength).forall(_.isNumber) && + items.drop(significantPartLength).forall { + case _: Version.Numeric => true + case _: Version.BuildMetadata => true + case _: Version.Tag => true + case _ => false // Min / Max + } && + !hasPreReleaseQualifier(version) + } + case object Default extends VersionCompatibility { def isCompatible(constraint: String, version: String): Boolean = PackVer.isCompatible(constraint, version) @@ -85,7 +131,7 @@ object VersionCompatibility { if (c.interval == VersionInterval.zero) c.preferred.exists { wanted => val toCompare = significativePartLength(v) - wanted.items.forall(_.isNumber) && + isSemVerComparable(wanted, toCompare) && wanted.items.take(toCompare) == v.items.take(toCompare) && { import Ordering.Implicits._ wanted.items.drop(toCompare) <= v.items.drop(toCompare) @@ -101,7 +147,7 @@ object VersionCompatibility { .filter(_.forall(_.isNumber)) .map(_.collect { case n: Version.Numeric => n }) .map(items => items.map(_.repr).mkString(".")) - .filter(s => Version(s).compareTo(v) <= 0) + .filter(s => Version(s).compareSemantic(v) <= 0) candidateOpt.getOrElse(version) } } @@ -118,7 +164,7 @@ object VersionCompatibility { val v = Version(version) if (c.interval == VersionInterval.zero) c.preferred.exists { wanted => - wanted.items.forall(_.isNumber) && + isSemVerComparable(wanted, 1) && wanted.items.take(1) == v.items.take(1) && v.items.take(1).exists(!_.isEmpty) && { import Ordering.Implicits._ @@ -134,7 +180,7 @@ object VersionCompatibility { .filter(items => items.nonEmpty && items.forall(_.isNumber) && items.forall(!_.isEmpty)) .map(_.collect { case n: Version.Numeric => n }) .map(items => items.map(_.repr).mkString(".")) - .filter(s => Version(s).compareTo(v) <= 0) + .filter(s => Version(s).compareSemantic(v) <= 0) candidateOpt.getOrElse(version) } } @@ -155,7 +201,7 @@ object VersionCompatibility { .filter(_.forall(_.isNumber)) .map(_.collect { case n: Version.Numeric => n }) .map(items => items.map(_.repr).mkString(".")) - .filter(s => Version(s).compareTo(v) <= 0) + .filter(s => Version(s).compareSemantic(v) <= 0) candidateOpt.getOrElse(version) } } diff --git a/versions/shared/src/coursier/version/VersionConstraint.scala b/versions/shared/src/coursier/version/VersionConstraint.scala index f6ffe25..1c9915a 100644 --- a/versions/shared/src/coursier/version/VersionConstraint.scala +++ b/versions/shared/src/coursier/version/VersionConstraint.scala @@ -18,13 +18,13 @@ sealed abstract class VersionConstraint extends Product with Serializable with O private lazy val compareKey = preferred.headOption.orElse(interval.from).getOrElse(Version.zero) def compare(other: VersionConstraint): Int = - compareKey.compare(other.compareKey) + compareKey.compareSemantic(other.compareKey) def isValid: Boolean = interval.isValid && preferred.forall { v => interval.contains(v) || interval.to.forall { to => - val cmp = v.compare(to) + val cmp = v.compareSemantic(to) cmp < 0 || (cmp == 0 && interval.toIncluded) } } @@ -87,7 +87,7 @@ object VersionConstraint { interval.from match { case Some(from) => allPreferred.filter { v => - val cmp = from.compare(v) + val cmp = from.compareSemantic(v) cmp < 0 || (cmp == 0 && interval.fromIncluded) } case None => diff --git a/versions/shared/src/coursier/version/VersionInterval.scala b/versions/shared/src/coursier/version/VersionInterval.scala index 476d400..8ed875f 100644 --- a/versions/shared/src/coursier/version/VersionInterval.scala +++ b/versions/shared/src/coursier/version/VersionInterval.scala @@ -12,7 +12,7 @@ case class VersionInterval( for { f <- from t <- to - cmd = f.compare(t) + cmd = f.compareSemantic(t) } yield cmd < 0 || (cmd == 0 && fromIncluded && toIncluded) fromToOrder.forall(x => x) && (from.nonEmpty || !fromIncluded) && (to.nonEmpty || !toIncluded) @@ -21,12 +21,12 @@ case class VersionInterval( def contains(version: Version): Boolean = { val fromCond = from.forall { from0 => - val cmp = from0.compare(version) + val cmp = from0.compareSemantic(version) cmp < 0 || cmp == 0 && fromIncluded } lazy val toCond = to.forall { to0 => - val cmp = version.compare(to0) + val cmp = version.compareSemantic(to0) cmp < 0 || cmp == 0 && toIncluded } @@ -49,7 +49,7 @@ case class VersionInterval( val (newFrom, newFromIncluded) = (from, other.from) match { case (Some(a), Some(b)) => - val cmp = a.compare(b) + val cmp = a.compareSemantic(b) if (cmp < 0) (Some(b), other.fromIncluded) else if (cmp > 0) (Some(a), fromIncluded) else (Some(a), fromIncluded && other.fromIncluded) @@ -62,7 +62,7 @@ case class VersionInterval( val (newTo, newToIncluded) = (to, other.to) match { case (Some(a), Some(b)) => - val cmp = a.compare(b) + val cmp = a.compareSemantic(b) if (cmp < 0) (Some(a), toIncluded) else if (cmp > 0) (Some(b), other.toIncluded) else (Some(a), toIncluded && other.toIncluded) diff --git a/versions/shared/test/src/coursier/version/VersionCompatibilityTests.scala b/versions/shared/test/src/coursier/version/VersionCompatibilityTests.scala index b891ce7..a50c221 100644 --- a/versions/shared/test/src/coursier/version/VersionCompatibilityTests.scala +++ b/versions/shared/test/src/coursier/version/VersionCompatibilityTests.scala @@ -115,6 +115,77 @@ object VersionCompatibilityTests extends TestSuite { } } + test("maven-style qualifiers") { + + // https://github.com/coursier/versions/issues/10 + // Netty and Jetty put a qualifier in a fourth, dot-separated segment. Those are releases, + // not pre-releases, so compatibility has to be decided on the numeric part. + + test("early semver") { + implicit val compat = VersionCompatibility.EarlySemVer + + test("netty") { + compatible("3.7.0.Final", "3.10.1.Final") + compatible("4.1.100.Final", "4.1.104.Final") + incompatible("3.10.1.Final", "3.7.0.Final") + incompatible("3.7.0.Final", "4.0.0.Final") + } + test("jetty") { + compatible("9.2.14.v20151106", "9.4.25.v20191220") + incompatible("9.4.25.v20191220", "9.2.14.v20151106") + incompatible("9.2.14.v20151106", "10.0.0.v20200101") + } + test("qualifier on one side only") { + compatible("4.1.100", "4.1.104.Final") + compatible("4.1.100.Final", "4.1.104") + } + test("other release qualifiers") { + compatible("1.2.3.RELEASE", "1.5.0.RELEASE") + compatible("1.2.3.GA", "1.5.0.GA") + compatible("8.0.1.jre8", "8.4.1.jre8") + } + test("still significant for 0.x") { + compatible("0.1.1.Final", "0.1.2.Final") + incompatible("0.1.1.Final", "0.2.0.Final") + } + } + + test("semver spec") { + implicit val compat = VersionCompatibility.SemVerSpec + + test { + compatible("3.7.0.Final", "3.10.1.Final") + } + test { + compatible("9.2.14.v20151106", "9.4.25.v20191220") + } + test { + incompatible("3.10.1.Final", "3.7.0.Final") + } + } + + test("pre-releases stay incompatible") { + // a qualifier right after a '-' is a semver pre-release, and a constraint on a + // pre-release only accepts itself + val compatibilities = Seq(VersionCompatibility.EarlySemVer, VersionCompatibility.SemVerSpec) + val preReleases = Seq( + "1.2.3-RC1", + "1.2.3-M3", + "1.2.3-alpha1", + "1.2.3-beta2", + "1.2.3-SNAPSHOT", + "1.2.3-milestone2", + // well-known markers count as pre-releases wherever they show up + "1.2.3.RC1", + "1.2.3.SNAPSHOT" + ) + for (compat <- compatibilities; wanted <- preReleases) { + val compatible = compat.isCompatible(wanted, "1.9.9") + Predef.assert(!compatible, s"Expected '1.9.9' not to be compatible with '$wanted' per $compat") + } + } + } + test("package versioning") { implicit val compat = VersionCompatibility.PackVer diff --git a/versions/shared/test/src/coursier/version/VersionTests.scala b/versions/shared/test/src/coursier/version/VersionTests.scala index ac09c6e..e5ee8b0 100644 --- a/versions/shared/test/src/coursier/version/VersionTests.scala +++ b/versions/shared/test/src/coursier/version/VersionTests.scala @@ -6,11 +6,17 @@ import utest._ object VersionTests extends TestSuite { def compare(first: String, second: String) = + Version(first).compareSemantic(Version(second)) + + def compareTotal(first: String, second: String) = Version(first).compare(Version(second)) def increasing(versions: String*): Boolean = versions.iterator.sliding(2).withPartial(false).forall{case Seq(a, b) => compare(a, b) < 0 } + def increasingTotal(versions: String*): Boolean = + versions.iterator.sliding(2).withPartial(false).forall{case Seq(a, b) => compareTotal(a, b) < 0 } + val tests = Tests { @@ -52,6 +58,20 @@ object VersionTests extends TestSuite { assert(compare("1.2+bar.1", "1.2+bar.2") == 0) } + test("total") { + // build metadata doesn't take part in precedence, but it still tells versions + // apart, so that the order stays total (see #13) + assert(compareTotal("1.2", "1.2+foo") < 0) + assert(compareTotal("2.0", "2.0+20130313144700") < 0) + assert(compareTotal("2.0+20130313144700", "2.0.2") < 0) + + assert(compareTotal("1.2+bar", "1.2+foo") < 0) + assert(compareTotal("1.2+bar.1", "1.2+bar.2") < 0) + + // scalajs-scalalib is published as + + assert(compareTotal("2.13.16+1.18.2", "2.13.16+1.19.0") < 0) + } + test("shouldNotParseMetadata") { test { val items = Version("1.2+bar.2").items @@ -199,18 +219,23 @@ object VersionTests extends TestSuite { assert(compare("1-rc", "1-cr" ) == 0) assert(compare("1-rc", "1-snapshot" ) < 0) assert(compare("1-snapshot", "1" ) < 0) - // ga, final and sp all denote releases, and go after the empty item, in that order. - // None of them is equivalent to another, nor to the empty item. - assert(compare("1", "1-ga" ) < 0) - assert(compare("1", "1.ga.0.ga" ) < 0) - assert(compare("1.0", "1-ga" ) < 0) - assert(compare("1", "1-ga.ga" ) < 0) - assert(compare("1", "1-ga-ga" ) < 0) - assert(compare("A", "A.ga.ga" ) < 0) - assert(compare("A", "A-ga-ga" ) < 0) - assert(compare("1-ga", "1-final" ) < 0) + // ga, final and sp all denote releases, and the total order puts them after the empty + // item, in that order, so that no two of them compare equal. ga and final stand for the + // absence of a qualifier though, so they don't change what a version means. + assert(compareTotal("1", "1-ga" ) < 0) + assert(compareTotal("1", "1.ga.0.ga" ) < 0) + assert(compareTotal("1.0", "1-ga" ) < 0) + assert(compareTotal("1", "1-ga.ga" ) < 0) + assert(compareTotal("1", "1-ga-ga" ) < 0) + assert(compareTotal("A", "A.ga.ga" ) < 0) + assert(compareTotal("A", "A-ga-ga" ) < 0) + assert(compareTotal("1-ga", "1-final" ) < 0) + assert(compareTotal("1", "1-final" ) < 0) + assert(compare("1", "1-ga" ) == 0) + assert(compare("1", "1-ga-ga" ) == 0) + assert(compare("1-ga", "1-final" ) == 0) + // sp is a release of its own, it isn't equivalent to the empty item assert(compare("1-final", "1-sp" ) < 0) - assert(compare("1", "1-final" ) < 0) assert(compare("1", "1-sp" ) < 0) assert(compare("2.12.4-bin-typelevel-4", "2.12.4" ) > 0) @@ -333,8 +358,10 @@ object VersionTests extends TestSuite { test("qualifierVersusNumberOrdering") { assert(compare("1-ga", "1-1" ) < 0) assert(compare("1.ga", "1.1" ) < 0) - assert(compare("1-ga", "1.0" ) > 0) - assert(compare("1.ga", "1.0" ) > 0) + // 1-ga and 1.0 mean the same version, only the total order tells them apart + assert(compareTotal("1-ga", "1.0" ) > 0) + assert(compareTotal("1.ga", "1.0" ) > 0) + assert(compare("1-ga", "1.0" ) == 0) // 1-0-1 has a longer numeric prefix, so 1-ga-1 is padded to 1-0-0-ga-1, and the // comparison is settled at the third item, before reaching the ga tag @@ -429,6 +456,102 @@ object VersionTests extends TestSuite { assert(items == expectedItems) } + test("totalOrder") { + // https://github.com/coursier/versions/issues/13 + // Versions that compareSemantic considers equal, but that aren't equal. + // ga, final and the empty item are distinct qualifiers, and single letter + // qualifiers are only expanded when followed by a digit, so 1-ga, 1-final + // and 1.0-a aren't part of any of these groups. + val equivalent = Seq( + Seq("1", "1.0", "1.0.0", "1.0.0.0", "1.0000000000000", "01", "1-"), + Seq("1.2", "1.2+foo", "1.2+bar", "1.02"), + // a lone a is a plain literal, not alpha - it only expands when followed by a digit + Seq("1.0-alpha", "1.0.0-alpha", "1.0alpha", "1.0.ALPHA", "1.0_alpha"), + Seq("1.0-rc", "1.0-cr", "1.0.0-rc", "1.0rc", "1.0.RC"), + Seq("1.0-m1", "1.0-milestone-1", "1.0m1", "1.0.0-milestone-1"), + Seq("1.1.0", "1.1", "1.1.0.Final", "1.1.0.GA", "1.1.0-final", "1.1.0-ga") + ) + + test("consistentWithEquals") { + for { + group <- equivalent + first <- group + second <- group + } { + val a = Version(first) + val b = Version(second) + // compare is only 0 for versions that are equal, unlike compareSemantic + assert((a.compare(b) == 0) == (a == b)) + assert(a.compareSemantic(b) == 0) + } + } + + test("antisymmetric") { + for { + group <- equivalent + first <- group + second <- group + } { + val a = Version(first) + val b = Version(second) + assert(math.signum(a.compare(b)) == -math.signum(b.compare(a))) + } + } + + test("sortedAndHashedAgree") { + for (group <- equivalent) { + val versions = group.map(Version(_)) + val hashed = versions.toSet + val sorted = scala.collection.immutable.TreeSet.empty[Version] ++ versions + assert(hashed.size == group.distinct.length) + assert(sorted.size == hashed.size) + assert(sorted == hashed) + } + } + + test("hashCodeConsistent") { + for { + group <- equivalent + first <- group + second <- group + if Version(first) == Version(second) + } assert(Version(first).hashCode == Version(second).hashCode) + } + + test("pvp") { + // PVP orders versions by the lexicographic ordering of their components, + // so extra trailing components make a version greater + assert(compareTotal("1.0.0", "1.0.0.0") < 0) + assert(compareTotal("1", "1.0") < 0) + assert(compareTotal("2.0.1", "1.3.2") > 0) + } + + test("releaseQualifiers") { + // ga and final stand for the absence of a qualifier, so they don't change + // what a version means + assert(compare("1.1.0", "1.1.0.Final") == 0) + assert(compare("1.1.0", "1.1.0.GA") == 0) + assert(compare("1.1.0.GA", "1.1.0.Final") == 0) + assert(compare("4.1.100.Final", "4.1.100") == 0) + + // compare still tells them apart, in the documented qualifier order + assert(compareTotal("1.1.0", "1.1.0.GA") < 0) + assert(compareTotal("1.1.0.GA", "1.1.0.Final") < 0) + assert(compareTotal("1.1.0.Final", "1.1.0.sp") < 0) + + // only ga and final are collapsed, sp is a release of its own + assert(compare("1.1.0", "1.1.0.sp") < 0) + assert(compare("1.1.0-SNAPSHOT", "1.1.0.Final") < 0) + } + + test("semanticOrderStillWins") { + // the tie-break only kicks in for versions that compareSemantic considers equal + assert(compareTotal("1.9", "1.10") < 0) + assert(compareTotal("1.0-SNAPSHOT", "1.0") < 0) + assert(compareTotal("1.0.0.0.0.1", "1.0.1") < 0) + } + } + test("isStable") { assert(Version("1.2.3").isStable) assert(Version("1.2.3-3").isStable) @@ -460,8 +583,10 @@ object VersionTests extends TestSuite { // "1.1-rc goes before 1.1-final (qualifier rc before final)" assert(compare("1.1-rc", "1.1-final") < 0) - // "1.1 goes before 1.1-final (empty item before qualifier final)" - assert(compare("1.1", "1.1-final") < 0) + // "1.1 goes before 1.1-final (empty item before qualifier final)" - in the total order. + // final stands for the absence of a qualifier, so both mean the same version. + assert(compareTotal("1.1", "1.1-final") < 0) + assert(compare("1.1", "1.1-final") == 0) // "1.1 goes before 1.1a (empty item before literal a)" assert(compare("1.1", "1.1a") < 0) @@ -491,7 +616,9 @@ object VersionTests extends TestSuite { // milestone (or m if directly followed by a digit), cr or rc, snapshot, then the empty // item itself, ga, final, sp. They all go before the plain literal items." test("orderingDocQualifierList") { - assert(increasing( + // that list is the total order - ga and final are equivalent to the empty item for + // compareSemantic, see the totalOrder tests + assert(increasingTotal( "1-alpha", "1-beta", "1-milestone", "1-rc", "1-snapshot", "1", "1-ga", "1-final", "1-sp", "1-zzz", "1-1" ))