Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 56 additions & 13 deletions versions/shared/src/coursier/version/Version.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
169 changes: 123 additions & 46 deletions versions/shared/test/src/coursier/version/VersionTests.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
}


Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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"))
}


Expand Down Expand Up @@ -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)
}
}

}