diff --git a/versions/shared/src/coursier/version/Version.scala b/versions/shared/src/coursier/version/Version.scala index 6119f45..4ae1845 100644 --- a/versions/shared/src/coursier/version/Version.scala +++ b/versions/shared/src/coursier/version/Version.scala @@ -92,33 +92,76 @@ object Version { } /** - * Tags represent prerelease tags, typically appearing after - for SemVer compatible versions. + * Tags represent the literal items of a version, like the prerelease tags typically + * appearing after - for SemVer compatible versions. + * + * A tag is either a qualifier, when its value has a special meaning (see [[Tag.level]]), + * or a plain literal. Qualifiers are ordered by level, some of them before the empty item + * and some after it, and plain literals go after all of them, ordered lexicographically. */ case class Tag(value: String) extends Item { val order = -1 - private val otherLevel = -5 lazy val level: Int = value match { - case "ga" | "final" | "" => 0 // 1.0.0 equivalent - case "snapshot" => -1 - case "rc" | "cr" => -2 - case "beta" | "b" => -3 - case "alpha" | "a" => -4 - case "dev" => -6 - case "sp" | "bin" => 1 - case _ => otherLevel + case "dev" => Tag.devLevel + case "alpha" => Tag.alphaLevel + case "beta" => Tag.betaLevel + case "milestone" => Tag.milestoneLevel + case "rc" | "cr" => Tag.rcLevel + case "snapshot" => Tag.snapshotLevel + case "" => Tag.emptyLevel + case "ga" => Tag.gaLevel + case "final" => Tag.finalLevel + case "sp" => Tag.spLevel + case _ => Tag.otherLevel } + /** Whether this tag is a qualifier, rather than a plain literal item. */ + def isQualifier: Boolean = level != Tag.otherLevel + override def compareToEmpty = level.compare(0) - def isPreRelease: Boolean = level < 0 + def isPreRelease: Boolean = level < Tag.emptyLevel def compareTag(other: Tag): Int = { val levelComp = level.compare(other.level) - if (levelComp == 0 && level == otherLevel) value.compareToIgnoreCase(other.value) + if (levelComp == 0 && level == Tag.otherLevel) value.compareToIgnoreCase(other.value) else levelComp } def withValue(value: String): Tag = copy(value = value) } + + object Tag { + // Qualifiers, in order. Those below emptyLevel denote pre-releases, those above it + // denote releases. Unlike Maven, ga and final are distinct, and distinct from the empty + // item, so that no two of them compare equal and sorting versions stays deterministic. + // dev isn't part of the documented ordering, it's kept as an extension, below alpha. + private[version] val devLevel = -6 + private[version] val alphaLevel = -5 + private[version] val betaLevel = -4 + private[version] val milestoneLevel = -3 + private[version] val rcLevel = -2 + private[version] val snapshotLevel = -1 + // An empty tag is the only one equivalent to the empty item. + private[version] val emptyLevel = 0 + private[version] val gaLevel = 1 + private[version] val finalLevel = 2 + private[version] val spLevel = 3 + // Plain literal items sort after all the qualifiers, and before non-zero numeric items. + private[version] val otherLevel = 4 + + // alpha, beta and milestone can be abbreviated to their initial, but only when that + // initial is directly followed by a digit: 1.1a1 is equivalent to 1.1-alpha-1, while + // 1.1a and 1.1a-1 have a plain literal a in them. + private[version] def expandAbbreviation(value: String, followedByDigit: Boolean): String = + if (followedByDigit) + value match { + case "a" => "alpha" + case "b" => "beta" + case "m" => "milestone" + case _ => value + } + else value + } case class BuildMetadata(value: String) extends Item { val order = 1 override def compareToEmpty = 0 @@ -170,7 +213,7 @@ object Version { case "x" if prev == Some(Dot) => Max case "min" => Min case "max" => Max - case _ => Tag(letters0) + case _ => Tag(Tag.expandAbbreviation(letters0, rem.headOption.exists(_.isDigit))) } (item, rem) } else { diff --git a/versions/shared/test/src/coursier/version/VersionTests.scala b/versions/shared/test/src/coursier/version/VersionTests.scala index 5eb3aa1..ac09c6e 100644 --- a/versions/shared/test/src/coursier/version/VersionTests.scala +++ b/versions/shared/test/src/coursier/version/VersionTests.scala @@ -193,20 +193,24 @@ object VersionTests extends TestSuite { assert(compare("1-alpha1", "1-a1" ) == 0) assert(compare("1-alpha", "1-beta" ) < 0) assert(compare("1-beta1", "1-b1" ) == 0) - assert(compare("1-beta", "1-milestone" ) > 0) - assert(compare("1-milestone1", "1-m1" ) > 0) + assert(compare("1-beta", "1-milestone" ) < 0) + assert(compare("1-milestone1", "1-m1" ) == 0) assert(compare("1-milestone", "1-rc" ) < 0) assert(compare("1-rc", "1-cr" ) == 0) assert(compare("1-rc", "1-snapshot" ) < 0) assert(compare("1-snapshot", "1" ) < 0) - 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", "1-final" ) == 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) + 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) @@ -220,50 +224,54 @@ object VersionTests extends TestSuite { test("wellKnownQualifierVersusUnknownQualifierOrdering") { assert(compare("1-milestone", "1-rc" ) < 0) - assert(compare("1-milestone", "1-beta" ) < 0) + assert(compare("1-milestone", "1-beta" ) > 0) assert(compare("1-M1", "1-rc1" ) < 0) - assert(compare("1-abc", "1-alpha" ) < 0) - assert(compare("1-abc", "1-beta" ) < 0) - assert(compare("1-abc", "1-milestone" ) < 0) - assert(compare("1-abc", "1-rc" ) < 0) - assert(compare("1-abc", "1-snapshot" ) < 0) - assert(compare("1-abc", "1" ) < 0) - assert(compare("1-abc", "1-sp" ) < 0) + // unknown qualifiers are plain literal items, they go after all the known + // qualifiers and after the empty item, but before non-zero numeric items + assert(compare("1-abc", "1-alpha" ) > 0) + assert(compare("1-abc", "1-beta" ) > 0) + assert(compare("1-abc", "1-milestone" ) > 0) + assert(compare("1-abc", "1-rc" ) > 0) + assert(compare("1-abc", "1-snapshot" ) > 0) + assert(compare("1-abc", "1" ) > 0) + assert(compare("1-abc", "1-sp" ) > 0) - assert(compare("1.0m", "1.0" ) < 0) - assert(compare("1.0-m", "1.0" ) < 0) - assert(compare("1.0.m", "1.0" ) < 0) + assert(compare("1.0m", "1.0" ) > 0) + assert(compare("1.0-m", "1.0" ) > 0) + assert(compare("1.0.m", "1.0" ) > 0) assert(compare("1.0m1", "1.0" ) < 0) assert(compare("1.0-m1", "1.0" ) < 0) assert(compare("1.0.m1", "1.0" ) < 0) - assert(compare("1.0m.1", "1.0" ) < 0) - assert(compare("1.0m-1", "1.0" ) < 0) + assert(compare("1.0m.1", "1.0" ) > 0) + assert(compare("1.0m-1", "1.0" ) > 0) assert(compare("1.0.1-MF", "1.0.0" ) > 0) - assert(compare("1.0.1-MF", "1.0.1" ) < 0) + assert(compare("1.0.1-MF", "1.0.1" ) > 0) assert(compare("1.0.1-MF", "1.0.2" ) < 0) assert(compare("1.0.1-X20", "1.0.0" ) > 0) - assert(compare("1.0.1-X20", "1.0.1" ) < 0) + assert(compare("1.0.1-X20", "1.0.1" ) > 0) assert(compare("1.0.1-X20", "1.0.2" ) < 0) assert(compare("1.0.1-SNAP12", "1.0.0" ) > 0) - assert(compare("1.0.1-SNAP12", "1.0.1" ) < 0) + assert(compare("1.0.1-SNAP12", "1.0.1" ) > 0) assert(compare("1.0.1-SNAP12", "1.0.2" ) < 0) } test("wellKnownSingleCharQualifiersOnlyRecognizedIfImmediatelyFollowedByNumber") { - assert(compare("1.0a", "1.0" ) < 0) - assert(compare("1.0-a", "1.0" ) < 0) - assert(compare("1.0.a", "1.0" ) < 0) - assert(compare("1.0b", "1.0" ) < 0) - assert(compare("1.0-b", "1.0" ) < 0) - assert(compare("1.0.b", "1.0" ) < 0) - assert(compare("1.0m", "1.0" ) < 0) - assert(compare("1.0-m", "1.0" ) < 0) - assert(compare("1.0.m", "1.0" ) < 0) - + // not followed by a digit, so plain literal items, which go after the empty item + assert(compare("1.0a", "1.0" ) > 0) + assert(compare("1.0-a", "1.0" ) > 0) + assert(compare("1.0.a", "1.0" ) > 0) + assert(compare("1.0b", "1.0" ) > 0) + assert(compare("1.0-b", "1.0" ) > 0) + assert(compare("1.0.b", "1.0" ) > 0) + assert(compare("1.0m", "1.0" ) > 0) + assert(compare("1.0-m", "1.0" ) > 0) + assert(compare("1.0.m", "1.0" ) > 0) + + // directly followed by a digit, so alpha / beta / milestone qualifiers assert(compare("1.0a1", "1.0" ) < 0) assert(compare("1.0-a1", "1.0" ) < 0) assert(compare("1.0.a1", "1.0" ) < 0) @@ -274,12 +282,13 @@ object VersionTests extends TestSuite { assert(compare("1.0-m1", "1.0" ) < 0) assert(compare("1.0.m1", "1.0" ) < 0) - assert(compare("1.0a.1", "1.0" ) < 0) - assert(compare("1.0a-1", "1.0" ) < 0) - assert(compare("1.0b.1", "1.0" ) < 0) - assert(compare("1.0b-1", "1.0" ) < 0) - assert(compare("1.0m.1", "1.0" ) < 0) - assert(compare("1.0m-1", "1.0" ) < 0) + // followed by a separator rather than a digit, so plain literal items again + assert(compare("1.0a.1", "1.0" ) > 0) + assert(compare("1.0a-1", "1.0" ) > 0) + assert(compare("1.0b.1", "1.0" ) > 0) + assert(compare("1.0b-1", "1.0" ) > 0) + assert(compare("1.0m.1", "1.0" ) > 0) + assert(compare("1.0m-1", "1.0" ) > 0) } @@ -324,9 +333,11 @@ 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) + assert(compare("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 assert(compare("1-ga-1", "1-0-1" ) < 0) assert(compare("1.ga.1", "1.0.1" ) < 0) @@ -386,8 +397,10 @@ object VersionTests extends TestSuite { assert(increasing( "1.0-alpha", "1.0", "1.0.1" )) assert(increasing( "1.0.alpha", "1.0", "1.0.1" )) - assert(increasing( "1.0-M1", "1.0-MF", "1.0-X1", "1.0-alpha1", "1.0-RC1", "1.0", "2.0", "2.0.2")) - assert(increasing( "1.0-MF", "1.0-X1", "1.0a", "1.0-RC1", "1.0", "2.0", "2.0.2")) + // M1 is a milestone, while MF, X1 and a are plain literal items, which sort + // after 1.0 rather than before it + assert(increasing( "1.0-alpha1", "1.0-M1", "1.0-RC1", "1.0", "1.0-MF", "1.0-X1", "2.0", "2.0.2")) + assert(increasing( "1.0-RC1", "1.0", "1.0a", "1.0-MF", "1.0-X1", "2.0", "2.0.2")) } @@ -424,6 +437,70 @@ object VersionTests extends TestSuite { assert(!Version("1.2.3-M2").isStable) assert(!Version("1.2.3-RC1").isStable) } + + // All the examples from + // https://get-coursier.io/docs/other-version-handling.html#ordering + test("orderingDocExamples") { + // "1.0.1 has the same ordering as 1.0-1 (actual separators don't matter)" + assert(compare("1.0.1", "1.0-1") == 0) + + // "1.0.1 or 1.0.1.0 goes before 1.0.1e (literal e goes after empty / zero items)" + assert(compare("1.0.1", "1.0.1e") < 0) + assert(compare("1.0.1.0", "1.0.1e") < 0) + + // "1.0.1e goes before 1.0.1.2 (literal e goes before non-zero numeric item 2)" + assert(compare("1.0.1e", "1.0.1.2") < 0) + + // "1.1.0 has same ordering as 1.1 (zero or empty items are equivalent)" + assert(compare("1.1.0", "1.1") == 0) + + // "1.1-alpha goes before 1.1-rc (qualifier alpha before rc)" + assert(compare("1.1-alpha", "1.1-rc") < 0) + + // "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.1a (empty item before literal a)" + assert(compare("1.1", "1.1a") < 0) + + // "1.1a goes before 1.1-foo (literal item a before literal foo)" + assert(compare("1.1a", "1.1-foo") < 0) + + // "1.1a is not equivalent to 1.1-alpha, as a is not followed by a digit. On the + // other hand, 1.1a1 is equivalent to 1.1-alpha-1, and 1.1a-1 is not, as a is + // followed by -, not by a digit." + assert(compare("1.1a", "1.1-alpha") != 0) + assert(compare("1.1a1", "1.1-alpha-1") == 0) + assert(compare("1.1a-1", "1.1-alpha-1") != 0) + + // "A last rule consists in ignoring any 0 items before a literal. For example: + // both 1.0-alpha-1 and 1.0.0-alpha-1 go before 1-beta, 1.0-beta, and 1.0.0-beta" + assert(compare("1.0-alpha-1", "1-beta") < 0) + assert(compare("1.0-alpha-1", "1.0-beta") < 0) + assert(compare("1.0-alpha-1", "1.0.0-beta") < 0) + assert(compare("1.0.0-alpha-1", "1-beta") < 0) + assert(compare("1.0.0-alpha-1", "1.0-beta") < 0) + assert(compare("1.0.0-alpha-1", "1.0.0-beta") < 0) + } + + // "Some literal items have a special meaning. These are, in comparison order: alpha + // (or a if directly followed by a digit), beta (or b if directly followed by a digit), + // 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( + "1-alpha", "1-beta", "1-milestone", "1-rc", "1-snapshot", "1", "1-ga", "1-final", + "1-sp", "1-zzz", "1-1" + )) + // cr and rc are two spellings of the same qualifier, so are m1 and milestone1 + assert(compare("1-cr", "1-rc") == 0) + assert(compare("1-m1", "1-milestone1") == 0) + // dev is a coursier extension, it isn't part of the documented list + assert(compare("1-dev", "1-alpha") < 0) + } } }