Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Apply updates that differ only in their artifactId at the same time #32

Merged
merged 9 commits into from Sep 18, 2018
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions build.sbt
Expand Up @@ -29,6 +29,7 @@ lazy val core = myCrossProject("core")
Dependencies.catsEffect,
Dependencies.circeParser,
Dependencies.fs2Core,
Dependencies.refined,
Dependencies.scalaTest % Test
),
assembly / test := {}
Expand Down
103 changes: 69 additions & 34 deletions modules/core/src/main/scala/eu/timepit/scalasteward/model/Update.scala
Expand Up @@ -18,57 +18,91 @@ package eu.timepit.scalasteward.model

import cats.data.NonEmptyList
import cats.implicits._
import eu.timepit.refined.types.string.NonEmptyString
import eu.timepit.scalasteward.model.Update.{Group, Single}
import eu.timepit.scalasteward.util

import scala.util.matching.Regex

final case class Update(
groupId: String,
artifactId: String,
currentVersion: String,
newerVersions: NonEmptyList[String]
) {

/** Returns true if the changes of applying `other` would include the changes
* of applying `this`.
*/
def isImpliedBy(other: Update): Boolean =
groupId === other.groupId &&
artifactId =!= other.artifactId &&
artifactId.startsWith(Update.removeIgnorableSuffix(other.artifactId)) &&
currentVersion === other.currentVersion &&
newerVersions === other.newerVersions
sealed trait Update extends Product with Serializable {
def groupId: String
def artifactId: String
def currentVersion: String
def newerVersions: NonEmptyList[String]

def name: String =
artifactId match {
case "core" => groupId.split('.').lastOption.getOrElse(groupId)
case _ => artifactId
}
if (Update.commonSuffixes.contains(artifactId))
groupId.split('.').lastOption.getOrElse(groupId)
else
artifactId

def nextVersion: String =
newerVersions.head

def replaceAllIn(str: String): Option[String] = {
def normalize(searchTerm: String): String =
def replaceAllIn(target: String): Option[String] = {
val quotedSearchTerms = searchTerms.map { term =>
Regex
.quoteReplacement(Update.removeIgnorableSuffix(searchTerm))
.quoteReplacement(Update.removeCommonSuffix(term))
.replace("-", ".?")

val regex =
s"(?i)(${normalize(name)}.*?)${Regex.quote(currentVersion)}".r
}
val searchTerm = quotedSearchTerms.mkString_("(", "|", ")")
val regex = s"(?i)($searchTerm.*?)${Regex.quote(currentVersion)}".r
var updated = false
val result = regex.replaceAllIn(str, m => {
val result = regex.replaceAllIn(target, m => {
updated = true
m.group(1) + nextVersion
})
if (updated) Some(result) else None
}

def show: String =
s"$groupId:$artifactId : ${(currentVersion :: newerVersions).mkString_("", " -> ", "")}"
def searchTerms: NonEmptyList[String] =
this match {
case s: Single => NonEmptyList.one(s.artifactId)
case g: Group => g.artifactIds.concat(g.artifactIdsPrefix.map(_.value).toList)
}

def show: String = {
val artifacts = this match {
case s: Single => s.artifactId
case g: Group => g.artifactIds.mkString_("{", ", ", "}")
}
val versions = (currentVersion :: newerVersions).mkString_("", " -> ", "")
s"$groupId:$artifacts : $versions"
}
}

object Update {
def fromString(str: String): Either[Throwable, Update] =
final case class Single(
groupId: String,
artifactId: String,
currentVersion: String,
newerVersions: NonEmptyList[String]
) extends Update

final case class Group(
groupId: String,
artifactIds: NonEmptyList[String],
currentVersion: String,
newerVersions: NonEmptyList[String]
) extends Update {
override def artifactId: String =
artifactIds.head

def artifactIdsPrefix: Option[NonEmptyString] =
util.longestCommonNonEmptyPrefix(artifactIds)
}

///

def apply(
groupId: String,
artifactId: String,
currentVersion: String,
newerVersions: NonEmptyList[String]
): Single =
Single(groupId, artifactId, currentVersion, newerVersions)

def fromString(str: String): Either[Throwable, Single] =
Either.catchNonFatal {
val regex = """([^\s:]+):([^\s:]+)[^\s]*\s+:\s+([^\s]+)\s+->(.+)""".r
str match {
Expand All @@ -78,8 +112,9 @@ object Update {
}
}

def removeIgnorableSuffix(str: String): String =
List("-core", "-server")
.find(suffix => str.endsWith(suffix))
.fold(str)(suffix => str.substring(0, str.length - suffix.length))
val commonSuffixes: List[String] =
List("core", "server")

def removeCommonSuffix(str: String): String =
util.removeSuffix(str, commonSuffixes)
}
25 changes: 19 additions & 6 deletions modules/core/src/main/scala/eu/timepit/scalasteward/sbt.scala
Expand Up @@ -19,6 +19,7 @@ package eu.timepit.scalasteward
import better.files.File
import cats.effect.IO
import eu.timepit.scalasteward.model.Update
import cats.implicits._

object sbt {
def addGlobalPlugins(home: File): IO[Unit] =
Expand All @@ -38,17 +39,29 @@ object sbt {
io.firejail(sbtCmd :+ ";dependencyUpdates ;reload plugins; dependencyUpdates", dir)
.map(lines => sanitizeUpdates(toUpdates(lines)))

def sanitizeUpdates(updates: List[Update]): List[Update] = {
val distinctUpdates = updates.distinct
distinctUpdates
.filterNot(update => distinctUpdates.exists(other => update.isImpliedBy(other)))
def sanitizeUpdates(updates: List[Update.Single]): List[Update] =
updates.distinct
.groupByNel(update => (update.groupId, update.currentVersion, update.newerVersions))
.values
.map { nel =>
val head = nel.head
if (nel.tail.nonEmpty)
Update.Group(
head.groupId,
nel.map(_.artifactId).sorted,
head.currentVersion,
head.newerVersions
)
else
head
}
.toList
.sortBy(update => (update.groupId, update.artifactId))
}

val sbtCmd: List[String] =
List("sbt", "-no-colors")

def toUpdates(lines: List[String]): List[Update] =
def toUpdates(lines: List[String]): List[Update.Single] =
lines.flatMap { line =>
val trimmed = line.replace("[info]", "").trim
Update.fromString(trimmed).toSeq
Expand Down
20 changes: 20 additions & 0 deletions modules/core/src/main/scala/eu/timepit/scalasteward/util.scala
Expand Up @@ -17,9 +17,29 @@
package eu.timepit.scalasteward

import cats.Monad
import cats.data.NonEmptyList
import cats.implicits._
import eu.timepit.refined.types.string.NonEmptyString

object util {
def ifTrue[F[_]: Monad](fb: F[Boolean])(f: F[Unit]): F[Unit] =
fb.ifM(f, Monad[F].unit)

def longestCommonPrefix(s1: String, s2: String): String = {
var i = 0
val min = math.min(s1.length, s2.length)
while (i < min && s1(i) == s2(i)) i = i + 1
s1.substring(0, i)
}

def longestCommonPrefix(xs: NonEmptyList[String]): String =
xs.reduceLeft(longestCommonPrefix)

def longestCommonNonEmptyPrefix(xs: NonEmptyList[String]): Option[NonEmptyString] =
NonEmptyString.unapply(longestCommonPrefix(xs))

def removeSuffix(target: String, suffixes: List[String]): String =
suffixes
.find(suffix => target.endsWith(suffix))
.fold(target)(suffix => target.substring(0, target.length - suffix.length))
}
Expand Up @@ -106,12 +106,30 @@ class UpdateTest extends FunSuite with Matchers {
.replaceAllIn(original) shouldBe Some(expected)
}

test("isImpliedBy") {
val update0 = Update("org.specs2", "specs2-core", "3.9.4", Nel.of("3.9.5"))
val update1 = update0.copy(artifactId = "specs2-scalacheck")
update0.isImpliedBy(update0) shouldBe false
update0.isImpliedBy(update1) shouldBe false
update1.isImpliedBy(update0) shouldBe true
update1.isImpliedBy(update1) shouldBe false
test("replaceAllIn: group with prefix val") {
val original = """ val circe = "0.10.0-M1" """
val expected = """ val circe = "0.10.0-M2" """
Update
.Group(
"io.circe",
Nel.of("circe-generic", "circe-literal", "circe-parser", "circe-testing"),
"0.10.0-M1",
Nel.of("0.10.0-M2")
)
.replaceAllIn(original) shouldBe Some(expected)
}

test("replaceAllIn: group with repeated version") {
val original =
""" "com.pepegar" %% "hammock-core" % "0.8.1",
| "com.pepegar" %% "hammock-circe" % "0.8.1"
""".stripMargin.trim
val expected =
""" "com.pepegar" %% "hammock-core" % "0.8.5",
| "com.pepegar" %% "hammock-circe" % "0.8.5"
""".stripMargin.trim
Update
.Group("com.pepegar", Nel.of("hammock-core", "hammock-circe"), "0.8.1", Nel.of("0.8.5"))
.replaceAllIn(original) shouldBe Some(expected)
}
}
Expand Up @@ -8,7 +8,14 @@ class sbtTest extends FunSuite with Matchers {
test("sanitizeUpdates") {
val update0 = Update("org.specs2", "specs2-core", "3.9.4", Nel.of("3.9.5"))
val update1 = update0.copy(artifactId = "specs2-scalacheck")
sbt.sanitizeUpdates(List(update0, update1)) shouldBe List(update0)
sbt.sanitizeUpdates(List(update0, update1)) shouldBe List(
Update.Group(
"org.specs2",
Nel.of("specs2-core", "specs2-scalacheck"),
"3.9.4",
Nel.of("3.9.5")
)
)
}

test("toUpdates") {
Expand Down
1 change: 1 addition & 0 deletions project/Dependencies.scala
Expand Up @@ -5,5 +5,6 @@ object Dependencies {
val catsEffect = "org.typelevel" %% "cats-effect" % "1.0.0"
val circeParser = "io.circe" %% "circe-parser" % "0.10.0-M2"
val fs2Core = "co.fs2" %% "fs2-core" % "1.0.0-M5"
val refined = "eu.timepit" %% "refined" % "0.9.2"
val scalaTest = "org.scalatest" %% "scalatest" % "3.0.5"
}