Repository: scala-steward-org/scala-steward (scala)

Issue:
Next version selection doesn't work for specific versions with hashes
Versions with commit hash containing `b` are not considered at all.

Here's a sample, modified from `VersionTest`'s `"selectNext, table 2"`:
```scala
val current = Version("1.2.0+9-4a769501")
val next = Version("1.2.0+17-7ef9b061")
current.selectNext(List(next)) shouldBe Some(next)
```

It seems that `b061` part gets parsed as `BETA` pre-release version, triggering `Do not select pre-release versions of a different series` case.

A quick workaround could be removing `b` clause in [Version.scala#L119](https://github.com/fthomas/scala-steward/blob/master/modules/core/src/main/scala/org/scalasteward/core/data/Version.scala#L119)

Pull request description:
This changes `Version.selectNext` in a few ways:
  * `isPreRelease` now returns `true`, if the version contains a hash that
    starts with `-` or `+`. So a `b` in the hash does not change the
    value of `isPreRelease` anymore.
  * Updates to a pre-release from a non-pre-release of the same series
    are explicitly prevented. 1.2.0 to 1.2.0+17-7ef98061 for example.
  * No more ad hoc rules that prevent updates where the new version
    contains a `-` or `+` when the previous version does not.
    1.2.3 to 1.2.3-4 for example are allowed now.
    These rules were added in #1134 and #1148.

This feels much more principled since hashes designate versions now as
pre-releases in addition to the other pre-release identifiers like
SNAPSHOT, MILESTONE, RC, and others.

Closes #1260.

Patch:
diff --git a/modules/core/src/main/scala/org/scalasteward/core/data/Version.scala b/modules/core/src/main/scala/org/scalasteward/core/data/Version.scala
index 85d717e784..8136dffd05 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/data/Version.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/data/Version.scala
@@ -28,18 +28,15 @@ final case class Version(value: String) {
     Version.Component.parse(value)
 
   val alnumComponents: List[Version.Component] =
-    components.filterNot {
-      case Version.Component.Separator(_) => true
-      case _                              => false
-    }
+    components.filter(_.isAlphanumeric)
 
   /** Selects the next version from a list of potentially newer versions.
     *
     * Implements the scheme described in this FAQ:
-    * https://github.com/fthomas/scala-steward/blob/master/docs/faq.md#how-does-scala-steward-decide-what-version-it-is-updating-to
+    * https://github.com/scala-steward-org/scala-steward/blob/master/docs/faq.md#how-does-scala-steward-decide-what-version-it-is-updating-to
     */
   def selectNext(versions: List[Version]): Option[Version] = {
-    val cutoff = preReleaseIndex.fold(alnumComponents.size)(_.value - 1)
+    val cutoff = alnumComponentsWithoutPreRelease.length - 1
     val newerVersionsByCommonPrefix =
       versions
         .filter(_ > this)
@@ -51,14 +48,12 @@ final case class Version(value: String) {
       .sortBy { case (commonPrefix, _) => commonPrefix.length }
       .flatMap {
         case (commonPrefix, vs) =>
+          val sameSeries = cutoff === commonPrefix.length
           vs.filterNot { v =>
-            // Do not select pre-release versions of a different series.
-            (v.isPreRelease && cutoff =!= commonPrefix.length) ||
-            // Do not select versions with a '+' or '-' if this is version does not
-            // contain such separator.
-            // E.g. 1.2.0 -> 1.2.0+17-7ef98061 or 3.1.0 -> 3.1.0-2156c0e.
-            (v.containsHyphen && !containsHyphen) ||
-            (v.containsPlus && !containsPlus) ||
+            // Do not select pre-releases of different series.
+            (v.isPreRelease && !sameSeries) ||
+            // Do not select pre-releases of the same series if this is not a pre-release.
+            (v.isPreRelease && !isPreRelease && sameSeries) ||
             // Don't select "versions" like %5BWARNING%5D.
             !v.startsWithLetterOrDigit
           }.sorted
@@ -66,12 +61,6 @@ final case class Version(value: String) {
       .lastOption
   }
 
-  private def containsHyphen: Boolean =
-    components.contains(Version.Component.Separator('-'))
-
-  private def containsPlus: Boolean =
-    components.contains(Version.Component.Separator('+'))
-
   private def startsWithLetterOrDigit: Boolean =
     components.headOption.forall {
       case Version.Component.Numeric(_)   => true
@@ -82,13 +71,21 @@ final case class Version(value: String) {
   private def isPreRelease: Boolean =
     preReleaseIndex.isDefined
 
-  private def preReleaseIndex: Option[NonNegInt] =
-    NonNegInt.unapply {
-      alnumComponents.indexWhere {
-        case a @ Version.Component.Alpha(_) => a.isPreReleaseIdent
-        case _                              => false
-      }
+  private[this] def alnumComponentsWithoutPreRelease: List[Version.Component] =
+    preReleaseIndex
+      .map(i => Version.Component.parse(value.substring(0, i.value)).filter(_.isAlphanumeric))
+      .getOrElse(alnumComponents)
+
+  private[this] val preReleaseIndex: Option[NonNegInt] = {
+    val preReleaseIdentIndex = components.indexWhere {
+      case a @ Version.Component.Alpha(_) => a.isPreReleaseIdent
+      case _                              => false
     }
+    NonNegInt.unapply(preReleaseIdentIndex).orElse(hashIndex)
+  }
+
+  private[this] def hashIndex: Option[NonNegInt] =
+    """[-+]\p{XDigit}{7,}""".r.findFirstMatchIn(value).flatMap(m => NonNegInt.unapply(m.start))
 }
 
 object Version {
@@ -106,7 +103,14 @@ object Version {
     (l1.padTo(maxLength, elem), l2.padTo(maxLength, elem))
   }
 
-  sealed trait Component extends Product with Serializable
+  sealed trait Component extends Product with Serializable {
+    final def isAlphanumeric: Boolean =
+      this match {
+        case Component.Numeric(_) => true
+        case Component.Alpha(_)   => true
+        case _                    => false
+      }
+  }
   object Component {
     final case class Numeric(value: String) extends Component {
       def isZero: Boolean = BigInt(value) === BigInt(0)


Test patch:
diff --git a/modules/core/src/test/scala/org/scalasteward/core/data/VersionTest.scala b/modules/core/src/test/scala/org/scalasteward/core/data/VersionTest.scala
index e2d65bdf2b..037b823655 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/data/VersionTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/data/VersionTest.scala
@@ -165,17 +165,26 @@ class VersionTest
       ("2.0.0-RC4", List("2.0.0-RC4-1"), Some("2.0.0-RC4-1")),
       ("2.0.0-RC4", List("2.1.0-RC4-1"), None),
       ("9.4.21.v20190926", List("10.0.0-alpha0"), None),
+      ("9.4.29.v20200521", List("9.4.30.v20200611"), Some("9.4.30.v20200611")),
       ("2.0.0.AM4", List("2.0.0"), Some("2.0.0")),
       ("1.1.2-1", List("1.1.2"), None),
+      ("1.1.2", List("1.1.2-1"), Some("1.1.2-1")),
       ("1.2.0+9-4a769501", List("1.2.0+17-7ef98061"), Some("1.2.0+17-7ef98061")),
+      ("1.2.0+9-4a7695012", List("1.2.0+17-7ef9b061"), Some("1.2.0+17-7ef9b061")),
+      ("1.2.0+9-4a76950123", List("1.2.0+17-7ef91060b"), Some("1.2.0+17-7ef91060b")),
+      ("1.2.0+9-4a769501234", List("1.2.0+17-7bf9106e"), Some("1.2.0+17-7bf9106e")),
       ("1.2.0", List("1.2.0+17-7ef98061"), None),
       ("1.2.0+17-7ef98061", List("1.3.0"), Some("1.3.0")),
       ("2.4.4", List("3.0.0-preview"), None),
       ("2.3.2", List("2.3.3-b02"), None),
+      ("2.3.2", List("2.3.3-b02-7ef9b061"), None),
       ("3.1.0", List("3.1.0-2156c0e"), None),
       ("3.1.0-2156c0e", List("3.2.0"), Some("3.2.0")),
       ("1.6.7", List("1.6.7-2-c28002d"), None),
       ("4.10.2", List("%5BWARNING%5D"), None),
+      ("2.1.4-11-307f3d8", List("2.1.4-13-fb16e4e"), Some("2.1.4-13-fb16e4e")),
+      ("2.1.4-13-fb16e4e", List("2.2.0", "2.2.0-0-fe5ed67"), Some("2.2.0")),
+      ("2.2.0", List("2.2.0-0-fe5ed67", "2.2.0-4-4bd225e"), None),
       ("v2-rev374-1.23.0", List("v2-rev20190917-1.30.3"), Some("v2-rev20190917-1.30.3"))
     )