From f2ac560fa79fe89e4ccfc086cc3788fc5344c81d Mon Sep 17 00:00:00 2001 From: Alex Archambault Date: Fri, 4 Sep 2026 01:20:31 +0200 Subject: [PATCH 1/4] Example in Version ordering doc does not match implementation The [documentation for Version ordering](https://get-coursier.io/docs/other-version-handling.html#ordering) contains this example: > 1.0.1 or 1.0.1.0 goes before 1.0.1e (literal e goes after empty / zero items) But this does not match the implementation which is demonstrated by the added test: ``` utest.AssertionError: assert(compare("1.0.1", "1.0.1e" ) < 0) ``` 1.0.1 and 1.0.1.0 are both actually greater than 1.0.1e: ```scala scala> Version("1.0.1").compare(Version("1.0.1e")) val res1: Int = 1 scala> Version("1.0.1.0").compare(Version("1.0.1e")) val res2: Int = 1 ``` --- .../shared/test/src/coursier/version/VersionTests.scala | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/versions/shared/test/src/coursier/version/VersionTests.scala b/versions/shared/test/src/coursier/version/VersionTests.scala index 5eb3aa1..5c5a71f 100644 --- a/versions/shared/test/src/coursier/version/VersionTests.scala +++ b/versions/shared/test/src/coursier/version/VersionTests.scala @@ -426,4 +426,10 @@ object VersionTests extends TestSuite { } } + test("orderingDocExamples") { + assert(compare("1.0.1", "1.0.1e") < 0) + assert(compare("1.0.1.0", "1.0.1e") < 0) + } + } + } From 5c1be785d90c96b3648961b37322fb3210ff6ce7 Mon Sep 17 00:00:00 2001 From: Alex Archambault Date: Fri, 4 Sep 2026 01:30:52 +0200 Subject: [PATCH 2/4] Make test pass Co-authored-by: Claude Opus 5 --- README.md | 6 +- README.template.md | 6 +- .../src/coursier/version/VersionTests.scala | 62 ++++++++++++++++++- 3 files changed, 69 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4d16323..eb86a24 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,11 @@ The parsing logic is originally based on [Maven / Aether version parsing](https: `items` gives the list of elements composing the parsed version, and is later used to compare versions. The coursier documentation [details](https://get-coursier.io/docs/other-version-handling.html#ordering) -how versions are compared. +how versions are compared. Note that this implementation diverges from it on a few points, the +main one being that unknown literal items are treated as pre-release qualifiers, so that they sort +*before* the release they lead to (`1.0-MF`, `1.0-X1` or `1.2.3-g12eafd3` all go before `1.0` / +`1.2.3`) rather than after it. See `orderingDocExamplesDivergences` in `VersionTests` for the +exhaustive list. `Version` implements `Ordered[Version]`, so that `Version` instances can be compared together, and a sequence of `Version`s can be sorted. diff --git a/README.template.md b/README.template.md index 19fbefe..df615b6 100644 --- a/README.template.md +++ b/README.template.md @@ -51,7 +51,11 @@ The parsing logic is originally based on [Maven / Aether version parsing](https: `items` gives the list of elements composing the parsed version, and is later used to compare versions. The coursier documentation [details](https://get-coursier.io/docs/other-version-handling.html#ordering) -how versions are compared. +how versions are compared. Note that this implementation diverges from it on a few points, the +main one being that unknown literal items are treated as pre-release qualifiers, so that they sort +*before* the release they lead to (`1.0-MF`, `1.0-X1` or `1.2.3-g12eafd3` all go before `1.0` / +`1.2.3`) rather than after it. See `orderingDocExamplesDivergences` in `VersionTests` for the +exhaustive list. `Version` implements `Ordered[Version]`, so that `Version` instances can be compared together, and a sequence of `Version`s can be sorted. diff --git a/versions/shared/test/src/coursier/version/VersionTests.scala b/versions/shared/test/src/coursier/version/VersionTests.scala index 5c5a71f..d2550ea 100644 --- a/versions/shared/test/src/coursier/version/VersionTests.scala +++ b/versions/shared/test/src/coursier/version/VersionTests.scala @@ -424,11 +424,67 @@ object VersionTests extends TestSuite { assert(!Version("1.2.3-M2").isStable) assert(!Version("1.2.3-RC1").isStable) } - } + // Examples from https://get-coursier.io/docs/other-version-handling.html#ordering + // Several of them don't match this implementation, see orderingDocExamplesDivergences below. test("orderingDocExamples") { - assert(compare("1.0.1", "1.0.1e") < 0) - assert(compare("1.0.1.0", "1.0.1e") < 0) + // "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.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.1a1 is equivalent to 1.1-alpha-1" + assert(compare("1.1a1", "1.1-alpha-1") == 0) + + // "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) + } + + // The coursier documentation describes the Maven / Aether ordering, but this + // implementation deliberately diverges from it on the points below. Those assertions + // pin the actual behaviour down, so that the divergences stay intentional. + test("orderingDocExamplesDivergences") { + + // The doc says "1.0.1 or 1.0.1.0 goes before 1.0.1e (literal e goes after empty / zero + // items)", and likewise "1.1 goes before 1.1a". Here, unknown literals are treated as + // pre-release qualifiers instead (Version.Tag.isPreRelease), so that versions like + // 1.0-MF, 1.0-X1 or 1.2.3-g12eafd3, which are common in the Scala ecosystem, sort + // before the release they lead to, rather than after it. + assert(compare("1.0.1", "1.0.1e") > 0) + assert(compare("1.0.1.0", "1.0.1e") > 0) + assert(compare("1.1", "1.1a") > 0) + + // For the same reason, an unknown literal sorts before the known qualifiers, rather + // than after them, so 1.1a (a is read as alpha, see below) goes after 1.1-foo. + assert(compare("1.1a", "1.1-foo") > 0) + + // milestone / m are not known qualifiers here, so they sort as unknown literals, + // below beta, rather than between beta and rc as the doc states. + assert(compare("1-milestone", "1-beta") < 0) + assert(compare("1-milestone", "1-rc") < 0) + + // The doc says "1.1-final goes before 1.1 (qualifier final before empty item)", but + // ga / final are equivalent to the empty item here (like in Maven). + assert(compare("1.1-final", "1.1") == 0) + + // Single char qualifiers are recognized whether or not they are directly followed by + // a digit, so, unlike what the doc states, 1.1a-1 is equivalent to 1.1-alpha-1. + assert(compare("1.1a-1", "1.1-alpha-1") == 0) } } From 810c3e055e753d2e55e66572263387b5275b2ec7 Mon Sep 17 00:00:00 2001 From: Alex Archambault Date: Fri, 4 Sep 2026 01:53:02 +0200 Subject: [PATCH 3/4] Behave according to the doc Co-authored-by: Claude Opus 5 --- README.md | 6 +- README.template.md | 6 +- .../shared/src/coursier/version/Version.scala | 62 ++++-- .../src/coursier/version/VersionTests.scala | 176 +++++++++--------- 4 files changed, 144 insertions(+), 106 deletions(-) diff --git a/README.md b/README.md index eb86a24..4d16323 100644 --- a/README.md +++ b/README.md @@ -58,11 +58,7 @@ The parsing logic is originally based on [Maven / Aether version parsing](https: `items` gives the list of elements composing the parsed version, and is later used to compare versions. The coursier documentation [details](https://get-coursier.io/docs/other-version-handling.html#ordering) -how versions are compared. Note that this implementation diverges from it on a few points, the -main one being that unknown literal items are treated as pre-release qualifiers, so that they sort -*before* the release they lead to (`1.0-MF`, `1.0-X1` or `1.2.3-g12eafd3` all go before `1.0` / -`1.2.3`) rather than after it. See `orderingDocExamplesDivergences` in `VersionTests` for the -exhaustive list. +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. diff --git a/README.template.md b/README.template.md index df615b6..19fbefe 100644 --- a/README.template.md +++ b/README.template.md @@ -51,11 +51,7 @@ The parsing logic is originally based on [Maven / Aether version parsing](https: `items` gives the list of elements composing the parsed version, and is later used to compare versions. The coursier documentation [details](https://get-coursier.io/docs/other-version-handling.html#ordering) -how versions are compared. Note that this implementation diverges from it on a few points, the -main one being that unknown literal items are treated as pre-release qualifiers, so that they sort -*before* the release they lead to (`1.0-MF`, `1.0-X1` or `1.2.3-g12eafd3` all go before `1.0` / -`1.2.3`) rather than after it. See `orderingDocExamplesDivergences` in `VersionTests` for the -exhaustive list. +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. diff --git a/versions/shared/src/coursier/version/Version.scala b/versions/shared/src/coursier/version/Version.scala index 6119f45..b110c58 100644 --- a/versions/shared/src/coursier/version/Version.scala +++ b/versions/shared/src/coursier/version/Version.scala @@ -92,33 +92,69 @@ 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 go before the empty item, in the order given by their + * level, and plain literals go after the empty item, 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 "ga" | "final" | "" => Tag.gaLevel + 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.gaLevel 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 all sort before the empty item, in this order. dev isn't part of the + // documented ordering, it's kept as an extension, right below alpha. + private[version] val devLevel = -8 + private[version] val alphaLevel = -7 + private[version] val betaLevel = -6 + private[version] val milestoneLevel = -5 + private[version] val rcLevel = -4 + private[version] val snapshotLevel = -3 + private[version] val gaLevel = -2 + private[version] val spLevel = -1 + // Plain literal items sort after the empty item, and before non-zero numeric items. + private[version] val otherLevel = 1 + + // 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 +206,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 d2550ea..daf5f6e 100644 --- a/versions/shared/test/src/coursier/version/VersionTests.scala +++ b/versions/shared/test/src/coursier/version/VersionTests.scala @@ -193,21 +193,22 @@ 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) - assert(compare("1", "1-sp" ) < 0) + // ga / final / sp are qualifiers, and all qualifiers go before 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", "1-final" ) > 0) + assert(compare("1", "1-sp" ) > 0) assert(compare("2.12.4-bin-typelevel-4", "2.12.4" ) > 0) @@ -220,50 +221,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 +279,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,13 +330,13 @@ 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) assert(compare("1-ga-1", "1-0-1" ) < 0) assert(compare("1.ga.1", "1.0.1" ) < 0) - assert(compare("1.sp", "1.0" ) > 0) + assert(compare("1.sp", "1.0" ) < 0) assert(compare("1.sp", "1.1" ) < 0) assert(compare("1-abc", "1-1" ) < 0) @@ -373,9 +379,10 @@ object VersionTests extends TestSuite { test("versionEvolution") { + // sp is a qualifier, so 1.0-sp-1 goes before 1.0, not after it assert(increasing( "0.9.9-SNAPSHOT", "0.9.9", "0.9.10-SNAPSHOT", "0.9.10", "1.0-alpha-2-SNAPSHOT", "1.0-alpha-2", "1.0-alpha-10-SNAPSHOT", "1.0-alpha-10", "1.0-beta-1-SNAPSHOT", "1.0-beta-1", - "1.0-rc-1-SNAPSHOT", "1.0-rc-1", "1.0-SNAPSHOT", "1.0", "1.0-sp-1-SNAPSHOT", "1.0-sp-1")) + "1.0-rc-1-SNAPSHOT", "1.0-rc-1", "1.0-SNAPSHOT", "1.0-sp-1-SNAPSHOT", "1.0-sp-1", "1.0")) assert(compare("1.0-sp-1", "1.0.1-alpha-1-SNAPSHOT") < 0) assert(increasing("1.0.1-alpha-1-SNAPSHOT", "1.0.1-alpha-1", "1.0.1-beta-1-SNAPSHOT", "1.0.1-beta-1", @@ -386,8 +393,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")) } @@ -425,12 +434,16 @@ object VersionTests extends TestSuite { assert(!Version("1.2.3-RC1").isStable) } - // Examples from https://get-coursier.io/docs/other-version-handling.html#ordering - // Several of them don't match this implementation, see orderingDocExamplesDivergences below. + // 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) @@ -443,10 +456,24 @@ 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.1a1 is equivalent to 1.1-alpha-1" + // "1.1-final goes before 1.1 (qualifier final before empty item)" + assert(compare("1.1-final", "1.1") < 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) - // "both 1.0-alpha-1 and 1.0.0-alpha-1 go before 1-beta, 1.0-beta, and 1.0.0-beta" + // "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) @@ -455,36 +482,19 @@ object VersionTests extends TestSuite { assert(compare("1.0.0-alpha-1", "1.0.0-beta") < 0) } - // The coursier documentation describes the Maven / Aether ordering, but this - // implementation deliberately diverges from it on the points below. Those assertions - // pin the actual behaviour down, so that the divergences stay intentional. - test("orderingDocExamplesDivergences") { - - // The doc says "1.0.1 or 1.0.1.0 goes before 1.0.1e (literal e goes after empty / zero - // items)", and likewise "1.1 goes before 1.1a". Here, unknown literals are treated as - // pre-release qualifiers instead (Version.Tag.isPreRelease), so that versions like - // 1.0-MF, 1.0-X1 or 1.2.3-g12eafd3, which are common in the Scala ecosystem, sort - // before the release they lead to, rather than after it. - assert(compare("1.0.1", "1.0.1e") > 0) - assert(compare("1.0.1.0", "1.0.1e") > 0) - assert(compare("1.1", "1.1a") > 0) - - // For the same reason, an unknown literal sorts before the known qualifiers, rather - // than after them, so 1.1a (a is read as alpha, see below) goes after 1.1-foo. - assert(compare("1.1a", "1.1-foo") > 0) - - // milestone / m are not known qualifiers here, so they sort as unknown literals, - // below beta, rather than between beta and rc as the doc states. - assert(compare("1-milestone", "1-beta") < 0) - assert(compare("1-milestone", "1-rc") < 0) - - // The doc says "1.1-final goes before 1.1 (qualifier final before empty item)", but - // ga / final are equivalent to the empty item here (like in Maven). - assert(compare("1.1-final", "1.1") == 0) - - // Single char qualifiers are recognized whether or not they are directly followed by - // a digit, so, unlike what the doc states, 1.1a-1 is equivalent to 1.1-alpha-1. - assert(compare("1.1a-1", "1.1-alpha-1") == 0) + // "Some literal items have a special meaning, and go before both literal and zero and + // non-zero numeric items. 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, ga or final, sp." + test("orderingDocQualifierList") { + assert(increasing( + "1-alpha", "1-beta", "1-milestone", "1-rc", "1-snapshot", "1-final", "1-sp", + "1", "1-zzz", "1-1" + )) + assert(compare("1-cr", "1-rc") == 0) + assert(compare("1-ga", "1-final") == 0) + // dev is a coursier extension, it isn't part of the documented list + assert(compare("1-dev", "1-alpha") < 0) } } From d0dd52c8650eb99b7ea6c328050b5bf73f373c45 Mon Sep 17 00:00:00 2001 From: Alex Archambault Date: Fri, 4 Sep 2026 02:02:26 +0200 Subject: [PATCH 4/4] Let's depart from the docs (and update them too) Co-authored-by: Claude Opus 5 --- .../shared/src/coursier/version/Version.scala | 55 +++++++++++-------- .../src/coursier/version/VersionTests.scala | 53 ++++++++++-------- 2 files changed, 60 insertions(+), 48 deletions(-) diff --git a/versions/shared/src/coursier/version/Version.scala b/versions/shared/src/coursier/version/Version.scala index b110c58..4ae1845 100644 --- a/versions/shared/src/coursier/version/Version.scala +++ b/versions/shared/src/coursier/version/Version.scala @@ -96,29 +96,31 @@ object Version { * 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 go before the empty item, in the order given by their - * level, and plain literals go after the empty item, ordered lexicographically. + * 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 lazy val level: Int = value match { - 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 "ga" | "final" | "" => Tag.gaLevel - case "sp" => Tag.spLevel - case _ => Tag.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 < Tag.gaLevel + def isPreRelease: Boolean = level < Tag.emptyLevel def compareTag(other: Tag): Int = { val levelComp = level.compare(other.level) if (levelComp == 0 && level == Tag.otherLevel) value.compareToIgnoreCase(other.value) @@ -129,18 +131,23 @@ object Version { } object Tag { - // Qualifiers all sort before the empty item, in this order. dev isn't part of the - // documented ordering, it's kept as an extension, right below alpha. - private[version] val devLevel = -8 - private[version] val alphaLevel = -7 - private[version] val betaLevel = -6 - private[version] val milestoneLevel = -5 - private[version] val rcLevel = -4 - private[version] val snapshotLevel = -3 - private[version] val gaLevel = -2 - private[version] val spLevel = -1 - // Plain literal items sort after the empty item, and before non-zero numeric items. - private[version] val otherLevel = 1 + // 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 diff --git a/versions/shared/test/src/coursier/version/VersionTests.scala b/versions/shared/test/src/coursier/version/VersionTests.scala index daf5f6e..ac09c6e 100644 --- a/versions/shared/test/src/coursier/version/VersionTests.scala +++ b/versions/shared/test/src/coursier/version/VersionTests.scala @@ -199,16 +199,19 @@ 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 / sp are qualifiers, and all qualifiers go before 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", "1-final" ) > 0) - assert(compare("1", "1-sp" ) > 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) @@ -330,13 +333,15 @@ 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) - assert(compare("1.sp", "1.0" ) < 0) + assert(compare("1.sp", "1.0" ) > 0) assert(compare("1.sp", "1.1" ) < 0) assert(compare("1-abc", "1-1" ) < 0) @@ -379,10 +384,9 @@ object VersionTests extends TestSuite { test("versionEvolution") { - // sp is a qualifier, so 1.0-sp-1 goes before 1.0, not after it assert(increasing( "0.9.9-SNAPSHOT", "0.9.9", "0.9.10-SNAPSHOT", "0.9.10", "1.0-alpha-2-SNAPSHOT", "1.0-alpha-2", "1.0-alpha-10-SNAPSHOT", "1.0-alpha-10", "1.0-beta-1-SNAPSHOT", "1.0-beta-1", - "1.0-rc-1-SNAPSHOT", "1.0-rc-1", "1.0-SNAPSHOT", "1.0-sp-1-SNAPSHOT", "1.0-sp-1", "1.0")) + "1.0-rc-1-SNAPSHOT", "1.0-rc-1", "1.0-SNAPSHOT", "1.0", "1.0-sp-1-SNAPSHOT", "1.0-sp-1")) assert(compare("1.0-sp-1", "1.0.1-alpha-1-SNAPSHOT") < 0) assert(increasing("1.0.1-alpha-1-SNAPSHOT", "1.0.1-alpha-1", "1.0.1-beta-1-SNAPSHOT", "1.0.1-beta-1", @@ -456,8 +460,8 @@ 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-final goes before 1.1 (qualifier final before empty item)" - assert(compare("1.1-final", "1.1") < 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) @@ -482,17 +486,18 @@ object VersionTests extends TestSuite { assert(compare("1.0.0-alpha-1", "1.0.0-beta") < 0) } - // "Some literal items have a special meaning, and go before both literal and zero and - // non-zero numeric items. 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, ga or final, sp." + // "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-final", "1-sp", - "1", "1-zzz", "1-1" + "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-ga", "1-final") == 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) }