Skip to content

Commit

Permalink
Adds possibility of upgrading a single dependency (#429)
Browse files Browse the repository at this point in the history
* Updated BuildUpdateDeps to use the Version algorithm from Scala Steward

* Adds possibility of upgrading as single dependency

can now pass --single <dep> to update-deps

it's possible to pass only an org or an org + module
e.g. passing org.http4s will upgrade all deps of the org.http4s
organization, while passing org.http4s::http4s-dsl will only upgrade
that dependency.

allows for an arbitrary amount amount of colon symbols between org and module.

* wip: Adding tests - Failing tests are not really failing - Poor
assumptions by me - fixing tonight

* New cli command update-dep and tests for dep updates

Moved the code for filtering deps and finding correct version to its own
object, added some tests for the logic

* Add documentation about dependency updates

* fix to updateSingleOrgOrModule being optional. Is mandatory

* Adds more details about Scala Steward strategy.

- Fixes some spelling errors.
- Adds tests for updating java deps.
  • Loading branch information
KristianAN authored Oct 5, 2024
1 parent 49288dd commit d0d702a
Show file tree
Hide file tree
Showing 14 changed files with 917 additions and 10 deletions.
20 changes: 19 additions & 1 deletion bleep-cli/src/scala/bleep/Main.scala
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,17 @@ object Main {

val watch = Opts.flag("watch", "start in watch mode", "w").orFalse

val updateAsScalaSteward = Opts
.flag(
"steward",
"Use same upgrade strategy as Scala Steward. Updates to the latest patch version at the same minor and major version. If the dependency is already on the latest patch version, it updates to the latest minor version at the same major version. And if the dependency is already on the latest minor version, it updates to the latest major version."
)
.orFalse

val updateWithPrerelease = Opts.flag("prerelease", "Allow upgrading to prerelease version if there is any.").orFalse

val updateSingleOrgOrModule = Opts.argument[String]("The dependency to update, alternatively only the organization name can be passed")

lazy val ret: Opts[BleepBuildCommand] = {
val allCommands = List(
List[Opts[BleepBuildCommand]](
Expand All @@ -140,7 +151,14 @@ object Main {
Opts(commands.BuildReinferTemplates(Set.empty))
),
Opts.subcommand("update-deps", "updates to newest versions of all dependencies")(
Opts(commands.BuildUpdateDeps)
(updateAsScalaSteward, updateWithPrerelease).mapN { case (sw, prerelease) =>
commands.BuildUpdateDeps.apply(sw, prerelease, None)
}
),
Opts.subcommand("update-dep", "update a single dependency or dependencies of a single organization to newest version(s)")(
(updateSingleOrgOrModule, updateAsScalaSteward, updateWithPrerelease).mapN { case (singleDep, sw, prerelease) =>
commands.BuildUpdateDeps.apply(sw, prerelease, Some(singleDep))
}
),
Opts.subcommand(
"move-files-into-bleep-layout",
Expand Down
83 changes: 75 additions & 8 deletions bleep-cli/src/scala/bleep/commands/BuildUpdateDeps.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package bleep
package commands

import bleep.internal.writeYamlLogged
import bleep.internal.Version
import bleep.rewrites.{normalizeBuild, UpgradeDependencies}
import coursier.Repository
import coursier.cache.FileCache
Expand All @@ -13,10 +14,14 @@ import scala.collection.immutable
import scala.concurrent.duration.Duration
import scala.concurrent.{Await, ExecutionContext, Future}
import scala.util.Success
import cats.parse.Parser
import bleep.model.Build
import bleep.model.{Dep, VersionCombo}

case class BuildUpdateDeps(scalaStewardMode: Boolean, allowPrerelease: Boolean, singleDep: Option[String]) extends BleepBuildCommand {

case object BuildUpdateDeps extends BleepBuildCommand {
override def run(started: Started): Either[BleepException, Unit] = {
val build = started.build.requireFileBacked("command update-deps")
val build: Build.FileBacked = started.build.requireFileBacked("command update-deps")
// a bleep dependency may be instantiated into several different coursier dependencies
// depending on which scala versions and platforms are plugging in
// collect all instantiations into this structure
Expand All @@ -31,12 +36,16 @@ case object BuildUpdateDeps extends BleepBuildCommand {
Await.result(fetchAllVersions(fileCache, repos, allDeps), Duration.Inf)
}

val upgrades: Map[UpgradeDependencies.ContextualDep, model.Dep] =
foundByDep.flatMap { case (tuple @ (bleepDep, _), (_, version)) =>
val latest = version.latest
if (latest == bleepDep.version) None
else Some(tuple -> bleepDep.withVersion(latest))
}
DependencyUpgrader.depsToUpgrade(singleDep, foundByDep, scalaStewardMode, allowPrerelease).flatMap { toUpdate =>
runUpdates(started, build, toUpdate)
}
}

private def runUpdates(
started: Started,
build: Build.FileBacked,
upgrades: Map[(Dep, VersionCombo), Dep]
): Right[BleepException, Unit] = {

val newBuild = UpgradeDependencies(new UpgradeLogger(started.logger), upgrades)(build, started.buildPaths)
val newBuild1 = normalizeBuild(newBuild, started.buildPaths)
Expand Down Expand Up @@ -87,4 +96,62 @@ case object BuildUpdateDeps extends BleepBuildCommand {
}
case Nil => Future.successful(None)
}

}

object DependencyUpgrader {
private val sepParser = Parser.char(':').rep.void

val singleDepParser = Parser.anyChar.repUntil(sepParser).string ~ (sepParser *> Parser.anyChar.repUntil(Parser.end).string).?

def depsToUpgrade(
singleDep: Option[String],
foundByDep: Map[UpgradeDependencies.ContextualDep, (Dependency, Versions)],
scalaStewardMode: Boolean,
allowPrerelease: Boolean
) = {
val singleDepParsed = singleDep.map(dep => singleDepParser.parseAll(dep))

singleDepParsed match {
case None => Right(findUpgrades(foundByDep, scalaStewardMode, allowPrerelease))
case Some(parsedDep) =>
filterDependencies(foundByDep, parsedDep, singleDep.getOrElse("")).map { filtered =>
findUpgrades(filtered, scalaStewardMode, allowPrerelease)
}
}
}

def filterDependencies(
foundByDep: Map[UpgradeDependencies.ContextualDep, (Dependency, Versions)],
parsedDep: Either[Parser.Error, (String, Option[String])],
depName: String
): Either[BleepException.Text, Map[UpgradeDependencies.ContextualDep, (Dependency, Versions)]] =
parsedDep match {
case Left(_) => Left(new BleepException.Text(s"${depName} is not a valid dependency name"))
case Right((org, module)) =>
val toUpdate = foundByDep.filter { case ((bleepDep, _), _) =>
module.map(bleepDep.baseModuleName.value.equalsIgnoreCase).getOrElse(true) && bleepDep.organization.value.equalsIgnoreCase(org)
}
if (toUpdate.isEmpty) {
Left(new BleepException.Text(s"$depName is not a dependency identifier in this build"))
} else {
Right(toUpdate)
}
}

def findUpgrades(
foundByDep: Map[UpgradeDependencies.ContextualDep, (Dependency, Versions)],
scalaStewardMode: Boolean,
allowPrerelease: Boolean
): Map[(Dep, VersionCombo), Dep] =
foundByDep.flatMap { case (tuple @ (bleepDep, _), (_, coursierVersion)) =>
val version = Version(bleepDep.version)
val latest =
if (scalaStewardMode) {
version.selectNext(coursierVersion.available.map(Version.apply), allowPrerelease)
} else {
version.selectLatest(coursierVersion.available.map(Version.apply), allowPrerelease)
}
latest.map(latestV => tuple -> bleepDep.withVersion(latestV.value))
}
}
240 changes: 240 additions & 0 deletions bleep-cli/src/scala/bleep/internal/Version.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
/*
* Copyright 2018-2023 Scala Steward contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package bleep

package internal

import bleep.internal.Version.startsWithDate
import cats.Order
import cats.implicits._
import cats.parse.{Numbers, Parser, Rfc5234}

final case class Version(value: String) {
override def toString: String = value

private val components: List[Version.Component] =
Version.Component.parse(value)

val alnumComponents: List[Version.Component] =
components.filter(_.isAlphanumeric)

/** Select the newest possible version for the dependency */
def selectLatest(versions: List[Version], preRelease: Boolean): Option[Version] = {
def recur(current: Version): Version =
current.selectNext(versions, false) match {
case None => current
case Some(value) => recur(value)
}

val next: Option[Version] = selectNext(versions, false).map(recur)

if (preRelease)
next.flatMap(_.selectNext(versions, true)).orElse(next)
else next
}

/** Selects the next version from a list of potentially newer versions.
*
* Implements the scheme described in this FAQ:
* https://github.com/scala-steward-org/scala-steward/blob/main/docs/faq.md#how-does-scala-steward-decide-what-version-it-is-updating-to
*/
def selectNext(versions: List[Version], allowPreReleases: Boolean = false): Option[Version] = {
val cutoff = alnumComponentsWithoutPreRelease.length - 1
val newerVersionsByCommonPrefix =
if (this.isPreRelease && allowPreReleases) {
versions.groupBy(_ => List.empty)
} else {
versions
.filter(_ > this)
.groupBy(_.alnumComponents.zip(alnumComponents).take(cutoff).takeWhile { case (c1, c2) =>
c1 === c2
})
}

newerVersionsByCommonPrefix.toList
.sortBy { case (commonPrefix, _) => commonPrefix.length }
.flatMap { case (commonPrefix, vs) =>
val sameSeries = cutoff === commonPrefix.length

val preReleasesFilter: Version => Boolean = v =>
// Do not select snapshots if we are not already on snapshot
v.isSnapshot && !isSnapshot

val releasesFilter: Version => Boolean = v =>
// 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) ||
// Do not select versions with pre-release identifiers whose order is smaller
// than the order of possible pre-release identifiers in this version. This,
// for example, prevents updates from 2.1.4.0-RC17 to 2.1.4.0-RC17+1-307f2f6c-SNAPSHOT.
(v.minAlphaOrder < minAlphaOrder) ||
// Do not select a version with hash if this version contains no hash.
(v.containsHash && !containsHash)

val commonFilter: Version => Boolean = v =>
// Do not select versions that are identical up to the hashes.
v.alnumComponents === alnumComponents ||
// Don't select "versions" like %5BWARNING%5D.
!v.startsWithLetterOrDigit

vs.filterNot { v =>
commonFilter(v) ||
(!allowPreReleases && releasesFilter(v)) ||
(allowPreReleases && preReleasesFilter(v))
}.sorted
}
.lastOption
}

private def startsWithLetterOrDigit: Boolean =
components.headOption.forall {
case _: Version.Component.Numeric => true
case a: Version.Component.Alpha => a.value.headOption.forall(_.isLetter)
case _ => false
}

private def isPreRelease: Boolean =
components.exists {
case a: Version.Component.Alpha => a.isPreReleaseIdent
case _: Version.Component.Hash => true
case _ => false
}

private def isSnapshot: Boolean =
components.exists {
case a: Version.Component.Alpha => a.isSnapshotIdent
case _: Version.Component.Hash => true
case _ => false
}

private def containsHash: Boolean =
components.exists {
case _: Version.Component.Hash => true
case _ => false
} || Rfc5234.hexdig.rep(8).string.filterNot(startsWithDate).parse(value).isRight

private[this] def alnumComponentsWithoutPreRelease: List[Version.Component] =
alnumComponents.takeWhile {
case a: Version.Component.Alpha => !a.isPreReleaseIdent
case _ => true
}

private val minAlphaOrder: Int =
alnumComponents.collect { case a: Version.Component.Alpha => a.order }.minOption.getOrElse(0)
}

object Version {

implicit val versionOrder: Order[Version] =
Order.from[Version] { (v1, v2) =>
val (c1, c2) = padToSameLength(v1.alnumComponents, v2.alnumComponents, Component.Empty)
c1.compare(c2)
}

private def padToSameLength[A](l1: List[A], l2: List[A], elem: A): (List[A], List[A]) = {
val maxLength = math.max(l1.length, l2.length)
(l1.padTo(maxLength, elem), l2.padTo(maxLength, elem))
}

private def startsWithDate(s: String): Boolean =
s.length >= 8 && s.substring(0, 8).forall(_.isDigit) && {
val year = s.substring(0, 4).toInt
val month = s.substring(4, 6).toInt
val day = s.substring(6, 8).toInt
(year >= 1900 && year <= 2100) &&
(month >= 1 && month <= 12) &&
(day >= 1 && day <= 31)
}

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 toBigInt: BigInt = BigInt(value)
}
final case class Alpha(value: String) extends Component {
def isPreReleaseIdent: Boolean = order < 0
def isSnapshotIdent: Boolean = order <= -6
def order: Int =
value.toUpperCase match {
case "SNAP" | "SNAPSHOT" | "NIGHTLY" => -6
case "ALPHA" | "PREVIEW" => -5
case "BETA" | "B" => -4
case "EA" /* early access */ => -3
case "M" | "MILESTONE" | "AM" => -2
case "RC" => -1
case _ => 0
}
}
final case class Hash(value: String) extends Component
final case class Separator(c: Char) extends Component
case object Empty extends Component

private val componentsParser = {
val digits = ('0' to '9').toSet
val separators = Set('.', '-', '_', '+')

val numeric = Numbers.digits.map(s => List(Numeric(s)))
val alpha = Parser.charsWhile(c => !digits(c) && !separators(c)).map(s => List(Alpha(s)))
val separator = Parser.charIn(separators).map(c => List(Separator(c)))
val hash = (Parser.charIn('-', '+') ~
Parser.char('g').string.? ~
Rfc5234.hexdig.rep(6).string.filterNot(startsWithDate)).backtrack
.map { case ((s, g), h) => List(Separator(s), Hash(g.getOrElse("") + h)) }

(numeric | alpha | hash | separator).rep0.map(_.flatten)
}

def parse(str: String): List[Component] =
componentsParser.parseAll(str).getOrElse(List.empty)

def render(components: List[Component]): String =
components.map {
case n: Numeric => n.value
case a: Alpha => a.value
case h: Hash => h.value
case s: Separator => s.c.toString
case Empty => ""
}.mkString

// This is similar to https://get-coursier.io/docs/other-version-handling.html#ordering
// but not exactly the same ordering as used by Coursier. One difference is that we are
// using different pre-release identifiers.
implicit val componentOrder: Order[Component] =
Order.from[Component] {
case (n1: Numeric, n2: Numeric) => n1.toBigInt.compare(n2.toBigInt)
case (_: Numeric, _) => 1
case (_, _: Numeric) => -1

case (a1: Alpha, a2: Alpha) =>
val (o1, o2) = (a1.order, a2.order)
if (o1 < 0 || o2 < 0) o1.compare(o2) else a1.value.compare(a2.value)

case (_: Alpha, Empty) => -1
case (Empty, _: Alpha) => 1

case _ => 0
}
}
}
Loading

0 comments on commit d0d702a

Please sign in to comment.