diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml deleted file mode 100644 index 32f49f4..0000000 --- a/.github/workflows/ci.yaml +++ /dev/null @@ -1,27 +0,0 @@ -on: - - pull_request - -jobs: - - test: - name: test - strategy: - matrix: - os: [macos, ubuntu] - include: - - os: ubuntu - runs: ubuntu-latest - artifact-name: sup-linux-amd - - os: macos - runs: macos-latest - artifact-name: sup-macos-amd - runs-on: ${{ matrix.runs }} - - steps: - - name: checkout - uses: actions/checkout@v2 - - uses: coursier/cache-action@v6 - - uses: olafurpg/setup-scala@v13 - - name: test for ${{ matrix.os }} - run: | - ./sbt test diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 0dba120..70aa0eb 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,86 +1,71 @@ on: push jobs: - build-and-upload: - name: build and upload + name: Build and Upload strategy: matrix: - os: [macos, ubuntu] + os: [ubuntu-latest, macos-latest] include: - - os: ubuntu - runs: ubuntu-latest + - os: ubuntu-latest artifact-name: sup-linux-amd - - os: macos - runs: macos-latest + - os: macos-latest artifact-name: sup-macos-amd - runs-on: ${{ matrix.runs }} + runs-on: ${{ matrix.os }} steps: - - name: checkout - uses: actions/checkout@v2 - - uses: coursier/cache-action@v6 + - name: Checkout + uses: actions/checkout@v3 - - name: test for ${{ matrix.os }} - run: | - ./sbt test + - name: Setup Scala + uses: olafurpg/setup-scala@v14 + with: + java-version: adopt@1.11 - - uses: olafurpg/setup-scala@v13 - - run: sbt ci-release - if: ${{ matrix.os == 'macos' }} - env: - PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} - PGP_SECRET: ${{ secrets.PGP_SECRET }} - SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} - SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} + - name: Test for ${{ matrix.os }} + run: sbt test - - uses: graalvm/setup-graalvm@v1 - if: ${{ startsWith(github.ref, 'refs/tags/') }} + - name: Setup GraalVM (for tagged commits) + uses: graalvm/setup-graalvm@v1 + if: startsWith(github.ref, 'refs/tags/') with: - version: 'latest' - java-version: '17' - components: 'native-image' + java-version: "21" github-token: ${{ secrets.GITHUB_TOKEN }} - - name: build for ${{ matrix.os }} - if: ${{ startsWith(github.ref, 'refs/tags/') }} - run: | - ./sbt graalvm-native-image:packageBin + - name: Build Native Image (for tagged commits) + run: sbt nativeImage + if: startsWith(github.ref, 'refs/tags/') - # disable on window: https://github.com/upx/upx/issues/559 - - name: run upx on ${{ matrix.os }} + - name: Compress using UPX (not on Windows) uses: svenstaro/upx-action@v2 - if: ${{ startsWith(github.ref, 'refs/tags/') }} + if: startsWith(github.ref, 'refs/tags/') && matrix.os != 'windows-latest' with: - file: target/graalvm-native-image/scala-update + file: target/native-image/scala-update args: --best --lzma - - name: upload ${{ matrix.os }} - uses: actions/upload-artifact@v2 - if: ${{ startsWith(github.ref, 'refs/tags/') }} + - name: Upload Artifact + uses: actions/upload-artifact@v3 + if: startsWith(github.ref, 'refs/tags/') with: name: ${{ matrix.artifact-name }} - path: target/graalvm-native-image/scala-update + path: target/native-image/scala-update - - name: release binaries + - name: Release Binaries uses: svenstaro/upload-release-action@v2 - if: ${{ startsWith(github.ref, 'refs/tags/') }} + if: startsWith(github.ref, 'refs/tags/') with: repo_token: ${{ secrets.GITHUB_TOKEN }} - file: target/graalvm-native-image/scala-update + file: target/native-image/scala-update asset_name: ${{ matrix.artifact-name }} overwrite: true tag: ${{ github.ref }} - - id: get_version - if: ${{ matrix.os == 'macos' && startsWith(github.ref, 'refs/tags/') }} - uses: battila7/get-version-action@v2 - - - name: bump homebrew formula - env: - HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_GITHUB_TOKEN }} - if: ${{ matrix.os == 'macos' && startsWith(github.ref, 'refs/tags/') }} + - name: Prepare Homebrew Formula Update (macOS only) + if: startsWith(github.ref, 'refs/tags/') && matrix.os == 'macos-latest' run: | brew tap kitlangton/tap - brew bump-formula-pr kitlangton/tap/scala-update -f --no-browse --no-audit \ - --url="https://github.com/kitlangton/scala-update/releases/download/${{ steps.get_version.outputs.version }}/${{ matrix.artifact-name }}" \ No newline at end of file + version=$(echo "${{ github.ref }}" | sed 's/refs\/tags\///') + brew bump-formula-pr kitlangton/tap/scala-update --no-browse --no-audit \ + --url="https://github.com/kitlangton/scala-update/releases/download/${version}/${{ matrix.artifact-name }}" + env: + HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }} diff --git a/.gitignore b/.gitignore index 4117292..8762c57 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,11 @@ - .bsp .idea -.bloop -.metals -.vscode +project/project/target/config-classes +project/target target +.vscode +.metals +.bloop +project/project +project/metals.sbt .DS_Store diff --git a/.scalafix.conf b/.scalafix.conf deleted file mode 100644 index 8025b84..0000000 --- a/.scalafix.conf +++ /dev/null @@ -1,3 +0,0 @@ -rules = [ - RemoveUnused -] \ No newline at end of file diff --git a/.scalafmt.conf b/.scalafmt.conf index 0962ae4..116287b 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,15 +1,10 @@ -version = "3.0.6" +version = "3.8.0" +runner.dialect = scala3 + maxColumn = 120 align.preset = most align.multiline = false -continuationIndent.defnSite = 2 -assumeStandardLibraryStripMargin = true -docstrings.style = Asterisk +rewrite.rules = [RedundantBraces, RedundantParens] +rewrite.scala3.convertToNewSyntax = true +rewrite.scala3.removeOptionalBraces = true docstrings.wrapMaxColumn = 80 -lineEndings = preserve -includeCurlyBraceInSelectChains = false -danglingParentheses.preset = true -optIn.annotationNewlines = true -newlines.alwaysBeforeMultilineDef = false -runner.dialect = scala213 -rewrite.rules = [RedundantBraces, RedundantParens] \ No newline at end of file diff --git a/build.sbt b/build.sbt index 8b00028..657b8f4 100644 --- a/build.sbt +++ b/build.sbt @@ -1,121 +1,31 @@ -inThisBuild( - List( - name := "scala-update", - normalizedName := "scala-update", - organization := "io.github.kitlangton", - scalaVersion := "2.13.8", - crossScalaVersions := Seq("2.13.8"), - homepage := Some(url("https://github.com/kitlangton/scala-update")), - licenses := List("Apache-2.0" -> url("http://www.apache.org/licenses/LICENSE-2.0")), - semanticdbEnabled := true, - semanticdbVersion := scalafixSemanticdb.revision, - developers := List( - Developer( - "kitlangton", - "Kit Langton", - "kit.langton@gmail.com", - url("https://github.com/kitlangton") - ) - ) - ) -) +ThisBuild / version := "0.1.0-SNAPSHOT" + +ThisBuild / scalaVersion := "3.4.1" + +val zioVersion = "2.0.21" -val commonConfigurationVersion = "2.9.0" -val coursierVersion = "2.1.9" -val scalaMetaVersion = "4.9.2" -val zioCliVersion = "0.5.0" -val zioJsonVersion = "0.6.2" -val zioNioVersion = "2.0.2" -val zioTuiVersion = "0.2.2" -val zioVersion = "2.0.21" +// /Users/kit/code/terminus +//val terminusLocal = ProjectRef(file("/Users/kit/code/terminus"), "terminusZioJVM") lazy val root = (project in file(".")) + .enablePlugins(JavaAppPackaging) + .enablePlugins(NativeImagePlugin) .settings( name := "scala-update", libraryDependencies ++= Seq( - "org.apache.commons" % "commons-configuration2" % commonConfigurationVersion, - "dev.zio" %% "zio" % zioVersion, - "dev.zio" %% "zio-cli" % zioCliVersion, - "dev.zio" %% "zio-macros" % zioVersion, - "dev.zio" %% "zio-nio" % zioNioVersion, - "dev.zio" %% "zio-json" % zioJsonVersion, - "dev.zio" %% "zio-streams" % zioVersion, - "dev.zio" %% "zio-test" % zioVersion % Test, - "dev.zio" %% "zio-test-magnolia" % zioVersion % Test, - "dev.zio" %% "zio-test-sbt" % zioVersion % Test, - "io.get-coursier" %% "coursier" % coursierVersion, - "org.scalameta" %% "scalameta" % scalaMetaVersion, - "io.github.kitlangton" %% "zio-tui" % zioTuiVersion + "org.scala-lang" %% "scala3-compiler" % "3.4.1", + "io.get-coursier" % "interface" % "1.0.19", + "com.lihaoyi" %% "pprint" % "0.8.1", + "dev.zio" %% "zio-nio" % "2.0.2", + "dev.zio" %% "zio" % zioVersion, + "dev.zio" %% "zio-streams" % zioVersion, + "dev.zio" %% "zio-test" % zioVersion % Test, + "io.github.kitlangton" %% "terminus-zio" % "0.0.9" ), - Compile / mainClass := Some("update.Main"), - testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework"), - graalVMNativeImageOptions ++= Seq( - "--no-fallback", - "--enable-url-protocols=https", - "--report-unsupported-elements-at-runtime" - ), - scalacOptions ++= Seq( - "-deprecation", - "-unchecked", - "-Wunused" -// "-Xfatal-warnings" - ) + mainClass := Some("update.Main"), + nativeImageVersion := "21.0.2", + nativeImageJvm := "graalvm-java21" ) - .enablePlugins(GraalVMNativeImagePlugin) +// .dependsOn(terminusLocal) Global / onChangedBuildSource := ReloadOnSourceChanges - -// I use this so I can run `sbt run` in this project. Very lazy hack. -//lazy val example = Seq( -// "dev.zio" %% "zio" % "1.0.14", -// "dev.zio" %% "zio-macros" % "1.0.14", -// "dev.zio" %% "zio-nio" % zioNioVersion, -// "dev.zio" %% "zio-streams" % "1.0.14", -// "dev.zio" %% "zio-test" % "1.0.14" % Test, -// "dev.zio" %% "zio-test-sbt" % "1.0.14" % Test, -// "io.get-coursier" %% "coursier" % coursierVersion, -// "org.scalameta" %% "scalameta" % "4.5.8", -// "io.github.kitlangton" %% "zio-tui" % "0.1.1", -// "io.github.neurodyne" %% "zio-aws-s3" % "0.4.12", -// "io.d11" %% "zhttp" % "2.0.0-RC8", -// "com.coralogix" %% "zio-k8s-client" % "1.4.6", -// "com.softwaremill.sttp.client3" %% "async-http-client-backend-zio" % "3.6.1", -// "nl.vroste" %% "zio-kinesis" % "0.21.1", -// "com.vladkopanev" %% "zio-saga-core" % "0.3.0", -// "io.scalac" %% "zio-slick-interop" % "0.3", -// "com.typesafe.slick" %% "slick-hikaricp" % "3.3.2", -// "info.senia" %% "zio-test-akka-http" % "1.0.14", -// "io.getquill" %% "quill-jdbc-zio" % "3.18.0", -// "dev.zio" %% "zio-akka-cluster" % "0.3.0", -// "dev.zio" %% "zio-cache" % "0.2.0", -// "dev.zio" %% "zio-config-magnolia" % "3.0.1", -// "dev.zio" %% "zio-config-typesafe" % "3.0.1", -// "dev.zio" %% "zio-config-refined" % "3.0.1", -// "dev.zio" %% "zio-ftp" % "0.3.6", -// "dev.zio" %% "zio-json" % "0.3.0-RC8", -// // "dev.zio" %% "zio-kafka" % "2.0.0-RC5", -// "dev.zio" %% "zio-logging" % "0.5.14", -// "dev.zio" %% "zio-metrics-prometheus" % "1.0.14", -// "dev.zio" %% "zio-nio" % "1.0.0-RC11", -// "dev.zio" %% "zio-optics" % "0.2.0", -// "dev.zio" %% "zio-prelude" % "1.0.0-RC9", -// "dev.zio" %% "zio-process" % "0.7.0", -// "dev.zio" %% "zio-rocksdb" % "0.3.2", -// "dev.zio" %% "zio-s3" % "0.3.7", -// "dev.zio" %% "zio-schema" % "0.2.0", -// "dev.zio" %% "zio-sqs" % "0.4.3", -// "dev.zio" %% "zio-opentracing" % "0.8.3", -// "io.laserdisc" %% "tamer-db" % "0.18.1", -// "io.jaegertracing" % "jaeger-core" % "1.6.0", -// "io.jaegertracing" % "jaeger-client" % "1.6.0", -// "io.jaegertracing" % "jaeger-zipkin" % "1.6.0", -// "io.zipkin.reporter2" % "zipkin-reporter" % "2.16.3", -// "io.zipkin.reporter2" % "zipkin-sender-okhttp3" % "2.16.3", -// "dev.zio" %% "zio-interop-cats" % "3.3.0", -// "dev.zio" %% "zio-interop-scalaz7x" % "7.3.3.0", -// "dev.zio" %% "zio-interop-reactivestreams" % "1.3.12", -// "dev.zio" %% "zio-interop-twitter" % "20.10.2", -// "dev.zio" %% "zio-zmx" % "0.0.13", -// "dev.zio" %% "zio-query" % "0.3.0", -// "org.polynote" %% "uzhttp" % "0.2.8" -//) diff --git a/project/build.properties b/project/build.properties index f2f1347..49214c4 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version = 1.9.9 \ No newline at end of file +sbt.version = 1.9.9 diff --git a/project/plugins.sbt b/project/plugins.sbt index c674542..808b744 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,4 +1,7 @@ -//addSbtPlugin("org.scalameta" % "sbt-native-image" % "0.3.2") -addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.11") -addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.5.11") -addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.10.4") +addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.16") +//addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.0.0") +//addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "2.0.1") +//addSbtPlugin("org.scalameta" % "sbt-native-image" % "0.6.0") +addSbtPlugin("org.scalameta" % "sbt-native-image" % "0.3.4") +addSbtPlugin("org.portable-scala" % "sbt-scala-native-crossproject" % "1.3.2") +addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.0-RC2") diff --git a/sbt b/sbt deleted file mode 100755 index 1ffc573..0000000 --- a/sbt +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/bash - -get_java_cmd() { - if [[ -n "$JAVA_HOME" ]] && [[ -x "$JAVA_HOME/bin/java" ]]; then - echo "$JAVA_HOME/bin/java" - else - echo "java" - fi -} - -# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong -move_to_project_dir() { - if [[ "$(uname)" == "Darwin" ]] && [[ "$HOME" == "$PWD" ]]; then - cd $(dirname $0) - fi -} - -move_to_project_dir - -SBT_LAUNCHER="$(dirname $0)/sbt-launch.jar" - -SBT_OPTS="-Xms512M -Xmx4048M -Xss4M" - -# todo: check java cmd - -# todo: help text - -$(get_java_cmd) ${SBT_OPTS} -jar ${SBT_LAUNCHER} "$@" diff --git a/sbt-launch.jar b/sbt-launch.jar deleted file mode 100644 index c065b47..0000000 Binary files a/sbt-launch.jar and /dev/null differ diff --git a/sbt.bat b/sbt.bat deleted file mode 100755 index 185012e..0000000 --- a/sbt.bat +++ /dev/null @@ -1,2 +0,0 @@ -set SCRIPT_DIR=%~dp0 -java -Xms512M -Xmx1536M -Xss1M -jar "%SCRIPT_DIR%sbt-launch.jar" %* diff --git a/src/main/scala/update/AppError.scala b/src/main/scala/update/AppError.scala deleted file mode 100644 index 082eb82..0000000 --- a/src/main/scala/update/AppError.scala +++ /dev/null @@ -1,7 +0,0 @@ -package update - -sealed trait AppError extends Throwable - -object AppError { - case object MissingBuildSbt extends AppError -} diff --git a/src/main/scala/update/CLI.scala b/src/main/scala/update/CLI.scala deleted file mode 100644 index 6c86b7a..0000000 --- a/src/main/scala/update/CLI.scala +++ /dev/null @@ -1,113 +0,0 @@ -package update - -import tui.TUI -import update.cli.{CliApp, CliState, DependencyState} -import tui.view.View -import zio._ - -final case class CLI(dependencyUpdater: DependencyUpdater, tui: TUI) { - - /** - * - Get a list of the users current dependencies [[Dependency]] - * - Parse the users build.sbt [[DependencyParser]] - * - Use scala.meta to semantically parse the Scala AST - * - Find the available versions for each dependency [[versions.Versions]] - * - Collect available newer versions across categories (major, minor, - * patch, pre-release) [[UpdateOptions]] - * - Display these options to the user, they select what they want. - * - Replace the versions in the source code. - */ - - val run: IO[Throwable, Unit] = { - for { - options0 <- dependencyUpdater.allUpdateOptions - options = options0.filter(_._2.isNonEmpty) - _ <- if (options.nonEmpty) runSelector(options) - else ZIO.debug(dependenciesUpToDateMessage) - } yield () - }.catchSome { // - case AppError.MissingBuildSbt => - ZIO.debug(missingBuildSbtErrorMessage) - } - - def runSelector( - options: Chunk[(DependencyWithLocation, UpdateOptions)] - ): IO[Throwable, Unit] = { - - // TODO: Refactor - // Groups dependencies by their location - val grouped: Chunk[DependencyState] = - Chunk - .from(options.groupBy(_._1.location).values.map { depsWithOpts => - val opts: UpdateOptions = depsWithOpts.head._2 - val deps: NonEmptyChunk[DependencyWithLocation] = - NonEmptyChunk.fromIterableOption(depsWithOpts.map(_._1)).get - DependencyState.from(deps, opts) - }) - .sortBy(_.dependencies.head.artifact.value) - - for { - selectedDeps <- CliApp.run(CliState(grouped, 0, Set.empty)).provideEnvironment(ZEnvironment(tui)) - _ <- ZIO.when(selectedDeps.nonEmpty) { - for { - _ <- dependencyUpdater.runUpdates(selectedDeps) - _ <- displayUpdateSuccessMessage(selectedDeps) - } yield () - } - } yield () - } - - private def displayUpdateSuccessMessage(selectedDeps: Chunk[(DependencyWithLocation, Version)]): UIO[Unit] = { - // TODO: Render Group as well - val longestArtifactName = selectedDeps.map(_._1.dependency.artifact.value.length).max - val longestVersion = selectedDeps.map(_._1.dependency.version.value.length).max - val depViews = - selectedDeps.map { case (dep, version) => - View.horizontal( - View.text(dep.dependency.artifact.value.padTo(longestArtifactName, ' ')).cyan, - View.text(dep.dependency.version.value.padTo(longestVersion, ' ')).cyan.dim, - View.text("→").cyan.dim, - View.text(version.value).green.underlined - ) - } - - ZIO.debug( - View - .vertical( - Chunk( - View.text("UPDATED DEPENDENCIES").blue, - View.text("────────────────────").blue.dim - ) ++ depViews: _* - ) - .padding(1) - .renderNow - ) - } - - private lazy val dependenciesUpToDateMessage: String = - View - .text("All of your dependencies are up to date! 🎉") - .blue - .padding(1) - .renderNow - - private lazy val missingBuildSbtErrorMessage: String = - View - .vertical( - View.text("SCALA INTERACTIVE UPDATE ERROR").red, - View.text("──────────────────────────────").red.dim, - View.horizontal( - "I could not find a", - View.text("build.sbt").red.underlined, - s"file in the current directory." - ), - View.text("Are you running this command from a valid sbt project root?").dim - ) - .padding(1) - .renderNow -} - -object CLI { - val live: ZLayer[DependencyUpdater with TUI, Nothing, CLI] = - ZLayer.fromFunction(apply _) -} diff --git a/src/main/scala/update/Dependency.scala b/src/main/scala/update/Dependency.scala deleted file mode 100644 index 05cd199..0000000 --- a/src/main/scala/update/Dependency.scala +++ /dev/null @@ -1,90 +0,0 @@ -package update - -final case class Group(value: String) extends AnyVal -final case class Artifact(value: String) extends AnyVal - -final case class PreRelease(value: String) extends AnyVal { - override def toString: String = value -} - -object PreRelease { - - implicit val ordering: Ordering[PreRelease] = new Ordering[PreRelease] { - private val Re = raw"([A-Za-z]+)(\d+)(\w+)?".r - override def compare(x: PreRelease, y: PreRelease): Int = - x.value match { - case Re("RC", n, _) => - y.value match { - case Re("RC", m, _) => n.toInt compare m.toInt - case _ => 1 - } - case Re("M", n, _) => - y.value match { - case Re("RC", _, _) => -1 - case Re("M", m, _) => n.toInt compare m.toInt - case _ => 1 - } - case _ => -1 - } - } - implicit val ordered = Ordered.orderingToOrdered[PreRelease] _ -} - -// major.minor.patch-prerelease -final case class Version(value: String) { - lazy val details: VersionDetails = - VersionDetails.fromString(value) - - def major: Int = details.major - def minor: Int = details.minor - def patch: Int = details.patch - def preRelease: Option[PreRelease] = details.preRelease - - def isNewerThan(that: Version): Boolean = - major > that.major || - (major == that.major && minor > that.minor) || - (major == that.major && minor == that.minor && patch > that.patch) || - (major == that.major && minor == that.minor && patch == that.patch && preRelease.isEmpty && that.preRelease.isDefined) || - (major == that.major && minor == that.minor && patch == that.patch && preRelease.isDefined && that.preRelease.isDefined && preRelease.get > that.preRelease.get) - -} - -object Version { - implicit val ordering: Ordering[Version] = - Ordering.by[Version, (Int, Int, Int, Option[PreRelease])](v => (v.major, v.minor, v.patch, v.preRelease)) -} - -// group %% artifact % version -final case class Dependency(group: Group, artifact: Artifact, version: Version) { - val isSbt: Boolean = Dependency.isSbt(group, artifact) -} - -object Dependency { - implicit val dependencyOrder: Ordering[Dependency] = - Ordering.by(d => (d.group.value, d.artifact.value, d.version.value)) - - val sbtGroup: Group = Group("org.scala-sbt") - val sbtArtifact: Artifact = Artifact("sbt") - - def sbt(version: String): Dependency = Dependency(sbtGroup, sbtArtifact, Version(version)) - - def isSbt(group: Group, artifact: Artifact): Boolean = group == sbtGroup && artifact == sbtArtifact -} - -object VersionDetails { - def fromString(string: String): VersionDetails = { - val parts = string.split("[.-]") - val major = parts.lift(0).flatMap(_.toIntOption).getOrElse(0) - val minor = parts.lift(1).flatMap(_.toIntOption).getOrElse(0) - val patch = parts.lift(2).flatMap(_.toIntOption).getOrElse(0) - val preRelease = parts.lift(3) - VersionDetails(major, minor, patch, preRelease.map(PreRelease(_))) - } -} - -final case class VersionDetails( - major: Int, - minor: Int, - patch: Int, - preRelease: Option[PreRelease] -) diff --git a/src/main/scala/update/DependencyParser.scala b/src/main/scala/update/DependencyParser.scala deleted file mode 100644 index 03c55f7..0000000 --- a/src/main/scala/update/DependencyParser.scala +++ /dev/null @@ -1,169 +0,0 @@ -package update - -import zio.{Chunk, ChunkBuilder} -import zio.nio.file.Path - -import scala.collection.mutable -import scala.meta._ -import scala.util.chaining.scalaUtilChainingOps - -final case class DependencyWithLocation( - dependency: Dependency, - location: Location -) - -final case class VersionWithLocation( - version: Version, - location: Location, - quote: Boolean = true -) - -final case class Location(path: Path, start: Int, end: Int) - -sealed trait SourceFile { - val path: Path - val content: String -} -object SourceFile { - final case class BuildPropertiesSourceFile(path: Path, content: String) extends SourceFile { - import org.apache.commons.configuration2.PropertiesConfiguration - import java.io.{ByteArrayInputStream, InputStreamReader} - - lazy val propertiesConfiguration: PropertiesConfiguration = - (new PropertiesConfiguration).tap { pc => - pc.read(new InputStreamReader(new ByteArrayInputStream(content.getBytes))) - } - } - - final case class Sbt1DialectSourceFile(path: Path, content: String) extends SourceFile { - import scala.meta._ - lazy val tree: Tree = dialects.Sbt1(content).parse[Source].get - } -} - -object DependencyParser { - def getDependencies(sources: Chunk[SourceFile]): Chunk[DependencyWithLocation] = { - lazy val nonSbtVersionDefs = collectNonSbtVersionDefs(sources) - val builder = ChunkBuilder.make[DependencyWithLocation]() - - sources.foreach { - case source @ SbtVersionFile(version, start) => - val dependency = Dependency.sbt(version.value) - val location = Location(source.path, start, start + version.value.length) - builder += DependencyWithLocation(dependency, location) - - case source: SourceFile.Sbt1DialectSourceFile => - source.tree.traverse { - // Matches String Literal Versions: "zio.dev" %% "zio" % "1.0.0" - case GroupAndArtifact(group, artifact, term @ Lit.String(version)) => - val location = Location(path = source.path, start = term.pos.start, end = term.pos.end) - val dependency = Dependency(group, artifact, Version(version)) - builder += DependencyWithLocation(dependency, location) - - // Matches Identifier Versions: "zio.dev" %% "zio" % zioVersion - // "zio.dev" %% "zio" % V.zio - case GroupAndArtifact(group, artifact, GetIdentifier(name)) if nonSbtVersionDefs.contains(name) => - val versionDef = nonSbtVersionDefs(name) - val location = versionDef.location - val dependency = Dependency(group, artifact, versionDef.version) - builder += DependencyWithLocation(dependency, location) - // Matches String Literal Versions: "zio.dev::zio:1.0.0" - case MillGroupArtifact(group, artifact, term @ Lit.String(version)) => - val location = Location(path = source.path, start = term.pos.start, end = term.pos.end) - val dependency = Dependency(group, artifact, Version(version)) - builder += DependencyWithLocation(dependency, location) - // Matches Identifier Versions: "zio.dev::zio:$zioVersion" - // "zio.dev::zio:${V.zio}" - case MillGroupArtifact(group, artifact, GetIdentifier(name)) if nonSbtVersionDefs.contains(name) => - val versionDef = nonSbtVersionDefs(name) - val location = versionDef.location - val dependency = Dependency(group, artifact, versionDef.version) - builder += DependencyWithLocation(dependency, location) - } - - case _ => - } - - builder.result() - } - - private[update] def collectNonSbtVersionDefs(sourceFiles: Chunk[SourceFile]): Map[String, VersionWithLocation] = - sourceFiles.collect { case f: SourceFile.Sbt1DialectSourceFile => parseVersionDefs(f) }.flatten.toMap - - private[update] def parseVersionDefs( - sourceFile: SourceFile.Sbt1DialectSourceFile - ): Map[String, VersionWithLocation] = { - val mutableMap = mutable.Map.empty[String, VersionWithLocation] - sourceFile.tree.traverse { case q"""val $identifier = ${term @ Lit.String(versionString)}""" => - val location = Location(sourceFile.path, term.pos.start, term.pos.end) - val versionWithLocation = VersionWithLocation(Version(versionString), location) - mutableMap += (identifier.syntax -> versionWithLocation) - } - mutableMap.toMap - } - - // # Helper Extractors for parsing dependency information - - private object GroupAndArtifact { - // Extracts: "group" %% "artifact" % "hello" - def unapply(tree: Tree): Option[(Group, Artifact, Term)] = - tree match { - case Term.ApplyInfix( - Term.ApplyInfix(Lit.String(group), Term.Name("%" | "%%" | "%%%"), _, List(Lit.String(artifact))), - Term.Name("%"), - _, - List(arg) - ) => - Some((Group(group), Artifact(artifact), arg)) - case _ => None - } - } - - private object MillGroupArtifact { - private val artifact = raw"([^:]*)::([^:]*)::?(\S)".r - private val artifactWithoutVersion = raw"([^:]*)::([^:]*)::?".r - - def unapply(tree: Tree): Option[(Group, Artifact, Term)] = - tree match { - // Extracts: ivy"group::artifact:$version" - case Term.Interpolate( - Term.Name("ivy"), - Lit.String(artifactWithoutVersion(group, artifact)) :: _, - List(arg) - ) => - Some((Group(group), Artifact(artifact), arg)) - // Extracts: ivy"group::artifact:version" - case Term.Interpolate( - Term.Name("ivy"), - Lit.String(artifact(group, artifact, version)) :: _, - _ - ) => - Some((Group(group), Artifact(artifact), Lit.String(version))) - case _ => None - } - } - - private object GetIdentifier { - def unapply(tree: Tree): Option[String] = - tree match { - // V.identifier - case Term.Select(_, identifier) => Some(identifier.syntax) - - // identifier - case Term.Name(identifier) => Some(identifier) - - // { } - case Term.Block(List(GetIdentifier(identifier))) => Some(identifier) - - case _ => None - } - } - - private object SbtVersionFile { - def unapply(sourceFile: SourceFile.BuildPropertiesSourceFile): Option[(Version, Int)] = - Option(sourceFile.propertiesConfiguration.getProperty("sbt.version")).map { _version => - val version = _version.asInstanceOf[String] - Version(version) -> sourceFile.content.indexOf(version) - } - } -} diff --git a/src/main/scala/update/DependencyUpdater.scala b/src/main/scala/update/DependencyUpdater.scala deleted file mode 100644 index 89f665a..0000000 --- a/src/main/scala/update/DependencyUpdater.scala +++ /dev/null @@ -1,71 +0,0 @@ -package update - -import update.versions.Versions -import zio._ -import zio.nio.file.Path - -import java.io.IOException - -case class DependencyUpdater(versions: Versions, files: Files) { - - def updateDependencies: IO[Throwable, Unit] = - for { - updates <- allUpdateOptions - newestVersions = updates.flatMap { case (dep, options) => - options.newestVersion.map(dep -> _) - } - _ <- runUpdates(Chunk.from(newestVersions)) - } yield () - - def runUpdates(updates: Chunk[(DependencyWithLocation, Version)]): IO[IOException, Unit] = { - val collected = groupUpdatesByFile(updates) - ZIO.foreachDiscard(collected) { case (path, updates) => - updateFile(path, updates) - } - - } - - def allUpdateOptions: IO[Throwable, Chunk[(DependencyWithLocation, UpdateOptions)]] = for { - pwd <- System.property("user.dir").someOrFailException - - sourceFiles <- files.allBuildSources(pwd) - deps = DependencyParser.getDependencies(sourceFiles) - sbtVersion = deps.collectFirst { case DependencyWithLocation(dep, _) if dep.isSbt => dep.version } - updates <- ZIO.foreachPar(deps)(dep => getUpdateOptions(dep, sbtVersion)) - } yield updates - - private def groupUpdatesByFile( - updates: Chunk[(DependencyWithLocation, Version)] - ): Map[Path, Chunk[VersionWithLocation]] = - updates.groupMap(_._1.location.path) { case (dep, version) => - VersionWithLocation(version, dep.location, quote = !dep.dependency.isSbt) - } - - private def updateFile(path: Path, updates: Chunk[VersionWithLocation]): IO[IOException, Unit] = - ZIO.scoped { - for { - oldContent <- ZIO.readFile(path.toString) - replacements = updates.map { case VersionWithLocation(version, location, shouldQuote) => - val quote = if (shouldQuote) "\"" else "" - Replacement(location.start, location.end, s"$quote${version.value}$quote") - } - newContent = Replacement.replace(oldContent, replacements.toList) - _ <- ZIO.writeFile(path.toString, newContent) - } yield () - } - - private def getUpdateOptions( - dep: DependencyWithLocation, - sbtVersion: Option[Version] - ): IO[Throwable, (DependencyWithLocation, UpdateOptions)] = - versions - .getVersions(dep.dependency.group, dep.dependency.artifact, sbtVersion) - .map { allVersions => - dep -> UpdateOptions.getOptions(dep.dependency.version, allVersions) - } -} - -object DependencyUpdater { - val live: ZLayer[Versions with Files, Nothing, DependencyUpdater] = - ZLayer.fromFunction(apply _) -} diff --git a/src/main/scala/update/Files.scala b/src/main/scala/update/Files.scala deleted file mode 100644 index 6d0316b..0000000 --- a/src/main/scala/update/Files.scala +++ /dev/null @@ -1,14 +0,0 @@ -package update - -import zio.{Chunk, IO, ULayer, ZLayer} - -import java.io.IOException - -trait Files { - def allBuildSources(path: String): IO[IOException, Chunk[SourceFile]] -} - -object Files { - val live: ULayer[FilesLive.type] = - ZLayer.succeed(FilesLive) -} diff --git a/src/main/scala/update/FilesLive.scala b/src/main/scala/update/FilesLive.scala deleted file mode 100644 index a5c9998..0000000 --- a/src/main/scala/update/FilesLive.scala +++ /dev/null @@ -1,39 +0,0 @@ -package update - -import zio.nio.file.Path -import zio.stream._ -import zio.{Chunk, IO, ZIO} - -import java.io.IOException - -case object FilesLive extends Files { - override def allBuildSources(root: String): IO[IOException, Chunk[SourceFile]] = { - val buildProperties = "build.properties" - - val rootPath = Path(root) - // Build files with Sbt1 dialect content - val projectScalaPaths = FileUtils.allScalaFiles(rootPath / "project") - val buildSbtPath = ZStream.succeed(rootPath / "build.sbt") - val buildMillPath = FileUtils.allMillFiles(rootPath) - val pluginsPath = ZStream.succeed(rootPath / "project" / "plugins.sbt") - // A .properties file - val sbtPropertiesFilePath = ZStream.succeed(rootPath / "project" / buildProperties) - - val allSourcePaths = (projectScalaPaths ++ buildSbtPath ++ pluginsPath ++ buildMillPath ++ sbtPropertiesFilePath) - .filterZIO(path => zio.nio.file.Files.exists(path)) - - allSourcePaths.mapZIO { path => - ZIO - .readFile(path.toString) - .map { content => - { - if (path.filename.toString == buildProperties) - SourceFile.BuildPropertiesSourceFile - else - SourceFile.Sbt1DialectSourceFile - }.tupled(path, content) - } - }.runCollect - } - -} diff --git a/src/main/scala/update/Main.scala b/src/main/scala/update/Main.scala index d0d98c7..d9f0397 100644 --- a/src/main/scala/update/Main.scala +++ b/src/main/scala/update/Main.scala @@ -1,87 +1,391 @@ package update +import update.services.* +import update.services.dependencies.* +import update.model.* +import zio.* +import terminus.* +import terminus.KeyCode.{Character, Down} +import update.model.Dependency +import zio.stream.ZStream +import terminus.components.ScrollList +import update.utils.Rewriter -import tui.TUI -import update.versions.Versions -import tui.view.{VerticalAlignment, View} -import zio._ - -sealed trait Subcommand extends Product with Serializable - -object Subcommand { - case object Interactive extends Subcommand - final case class Search(query: String) extends Subcommand - - def parse(input: List[String]): Option[Subcommand] = - input match { - case Nil => - Some(Interactive) - case "search" :: query :: Nil => - Some(Search(query)) - case _ => - None - } -} - -object Main extends ZIOAppDefault { - - val run = { - for { - args <- getArgs - subcommand = Subcommand.parse(args.toList) - _ <- subcommand match { - case Some(Subcommand.Interactive) => - runInteractive - case Some(Subcommand.Search(query)) => - runSearch(query) - case None => - helpMessage - } - } yield () - } +import scala.annotation.tailrec - lazy val runInteractive = - ZIO - .serviceWithZIO[CLI](_.run) - .provide( - CLI.live, - TUI.live(false), - DependencyUpdater.live, - Versions.live, - Files.live - ) +final case class DependencyState( + dependencies: NonEmptyChunk[Dependency], + sourceInfo: SourceInfo, + currentVersion: Version, + updateOptions: UpdateOptions, + selectedVersionType: VersionType +): + @tailrec + def nextVersion: DependencyState = + val nextVersionType = selectedVersionType.next + val next = copy(selectedVersionType = nextVersionType) + if updateOptions.hasVersionType(nextVersionType) then next + else next.nextVersion + + @tailrec + def previousVersion: DependencyState = + val previousVersionType = selectedVersionType.prev + val previous = copy(selectedVersionType = previousVersionType) + if updateOptions.hasVersionType(previousVersionType) then previous + else previous.previousVersion + + def selectedVersion: Version = + selectedVersionType match + case VersionType.Major => updateOptions.major.get + case VersionType.Minor => updateOptions.minor.get + case VersionType.Patch => updateOptions.patch.get + case VersionType.PreRelease => updateOptions.preRelease.get + +object DependencyState: + def from(dependency: WithVersions[WithSource[Dependency]]): Option[DependencyState] = + val dep = dependency.value.value + val versions = dependency.versions + val current = dep.version + val options = UpdateOptions.getOptions(current, versions) + val sourceInfo = dependency.value.sourceInfo + val maybeVersionType = options match + case UpdateOptions(Some(_), _, _, _) => Some(VersionType.Major) + case UpdateOptions(_, Some(_), _, _) => Some(VersionType.Minor) + case UpdateOptions(_, _, Some(_), _) => Some(VersionType.Patch) + case UpdateOptions(_, _, _, Some(_)) => Some(VersionType.PreRelease) + case _ => None + maybeVersionType.map(versionType => DependencyState(NonEmptyChunk(dep), sourceInfo, current, options, versionType)) + +object ScalaUpdateCLI extends TerminalAppZIO[List[DependencyState]]: + enum Message: + case LoadDependencies(dependencies: List[WithSource[Dependency]]) + case LoadVersions(dependency: WithVersions[WithSource[Dependency]]) + + sealed trait State + object State: + case object Loading extends State + case class Loaded( + totalDependencyCount: Int, + totalFinishedCount: Int, + dependencies: List[DependencyState], + selected: Set[SourceInfo], + currentIndex: Int, + showGroup: Boolean = false + ) extends State: + def moveUp: Loaded = + copy(currentIndex = currentIndex - 1).clampIndex + + def moveDown: Loaded = + copy(currentIndex = currentIndex + 1).clampIndex + + def nextVersion: Loaded = + val newDependencies = dependencies.updated(currentIndex, dependencies(currentIndex).nextVersion) + copy(dependencies = newDependencies) + + def previousVersion: Loaded = + val newDependencies = dependencies.updated(currentIndex, dependencies(currentIndex).previousVersion) + copy(dependencies = newDependencies) + + def incrementFinishedCount: Loaded = + copy(totalFinishedCount = totalFinishedCount + 1) + + def withVersions(dep: WithVersions[WithSource[Dependency]]): Loaded = + DependencyState.from(dep) match + case Some(state) => + // if existing with same sourceInfo, append dependency + // otherwise add new state + val updated = dependencies.find(_.sourceInfo == state.sourceInfo) match + case Some(existing) => + val newDependencies = existing.dependencies ++ state.dependencies + dependencies.updated(dependencies.indexOf(existing), existing.copy(dependencies = newDependencies)) + case None => + dependencies.appended(state) + copy(dependencies = updated.sortBy(_.dependencies.head)) - private def runSearch(query: String) = - search.Search().searchCLI(query) + case None => + this - lazy val helpMessage = { - val view = + def toggleCurrent: Loaded = + if selected.contains(currentSourceInfo) then copy(selected = selected - currentSourceInfo) + else copy(selected = selected + currentSourceInfo) + + def currentSourceInfo: SourceInfo = + dependencies(currentIndex).sourceInfo + + def selectedStates: List[DependencyState] = + dependencies.filter(dep => selected.contains(dep.sourceInfo)) + + // if not all selected, select all + // if all selected, deselect all + def toggleAll: Loaded = + if selected.size == dependencies.size then copy(selected = Set.empty) + else copy(selected = dependencies.map(_.sourceInfo).toSet) + + private def clampIndex: Loaded = + val nextSelected = + if currentIndex < 0 then dependencies.length - 1 + else if currentIndex >= dependencies.length then 0 + else currentIndex + copy(currentIndex = nextSelected) + end Loaded + end State + + def renderDependency( + state: State.Loaded, + firstColumnWidth: Int, + maxCurrentVersionWidth: Int, + dependencyState: DependencyState, + index: Int + ): View = + val dependencies = dependencyState.dependencies + val sourceInfo = dependencyState.sourceInfo + val isCurrent = state.currentIndex == index + val isSelected = state.selected.contains(sourceInfo) + + val selectedVersionType = dependencyState.selectedVersionType +// val versions = dependencyState.updateOptions.versions + val updateOptions = dependencyState.updateOptions + def optionView(versionType: VersionType, version: Option[Version], color: Color = Color.Green) = + version.map { version => + if versionType == selectedVersionType then + View + .text(version.toString) + .color(color) + .underline(isSelected) + else View.text(version.toString).color(color).dim + } + + val versionsView = + View.horizontal( + optionView(VersionType.Major, updateOptions.major), + optionView(VersionType.Minor, updateOptions.minor), + optionView(VersionType.Patch, updateOptions.patch), + optionView(VersionType.PreRelease, updateOptions.preRelease, Color.Magenta), + Option.when(isCurrent)( + View.text(dependencyState.selectedVersionType.toString).yellow + ) + ) + + def renderSingle(dependency: Dependency) = View - .vertical( - View.text("SCALA UPDATE COMMANDS").blue, - View.text("─────────────────────").blue.dim, - "", - View.horizontal(1, VerticalAlignment.top)( - View.text("1.").dim, - View.vertical( - View.text("scala-update").blue.bold, - View.text("Interactively update your library dependencies.").dim + .horizontal( + Option.when(state.showGroup)( + Seq( + View.text(dependency.group.value), + View.text(if dependency.isJava then "%" else "%%").dim ) ), - "", - View.horizontal(1, VerticalAlignment.top)( - View.text("2.").dim, - View.vertical( - View.horizontal( - View.text("scala-update").blue.dim, - View.text("search").blue.bold, - View.text("").blue - ), - View.text("Search for Maven-hosted libraries.").dim - ) + View.text(dependency.artifact.value) + ) + .width(firstColumnWidth) + + View + .horizontal( + if isCurrent then View.text("❯").bold else View.text(" ").dim, + if isSelected then View.text("◉").bold.green else View.text("○").dim, + View.vertical( + dependencies.map(renderSingle).toList + ), + View + .text(dependencyState.currentVersion.toString) + .dim + .width(maxCurrentVersionWidth), + View.text("→").dim, + versionsView + ) + + val divider: View = + View + .geometryReader { size => + View.text("─" * size.width) + } + .fillHorizontal + .color(Color.Blue) + + def header(state: State) = + val stats: View = state match + case State.Loading => + View.text("Loading...") + case loaded: State.Loaded => + View.horizontal( + s"${loaded.totalFinishedCount}", + View.text(s"/ ${loaded.totalDependencyCount} analyzed").dim, + View.text("•").dim, + View.text(s"${loaded.dependencies.flatMap(_.dependencies).length}"), + View.text(s"updates").dim + ) + View + .geometryReader { size => + View + .vertical( + View.horizontal( + View.text("SCALA UPDATE").bold, + View.spacer, + stats + ), + "─" * size.width ) + } + .fillHorizontal + .color(Color.Blue) + + // space toggle a toggle all ↑/↓ move up/dow g show groups q quit + + def renderCommand(key: String, description: String): View = + View.horizontal(View.text(key), View.text(description).dim).blue + + def renderCommands(loaded: State.Loaded) = + View.horizontal(2)( + renderCommand("space", "toggle"), + renderCommand("a", "toggle all"), + renderCommand("↑/↓", "up/down"), + renderCommand("g", if loaded.showGroup then "hide groups" else "show groups"), + renderCommand("q", "quit") + ) + + override def render(state: State): View = + val body = state match + case loaded: State.Loaded => + val maxGroupArtifactWidth = loaded.dependencies + .flatMap(_.dependencies) + .map { dep => + val group = if loaded.showGroup then s"${dep.group.value} ${if dep.isJava then "%" else "%%"} " else "" + s"$group${dep.artifact.value}".length + } + .maxOption + .getOrElse(0) + + val maxCurrentVersionWidth = loaded.dependencies + .map(_.currentVersion.toString.length) + .maxOption + .getOrElse(0) + + View.vertical( // + ScrollList( + loaded.dependencies.zipWithIndex.map { (dep, index) => + renderDependency(loaded, maxGroupArtifactWidth, maxCurrentVersionWidth, dep, index) + }, + loaded.currentIndex + ).fillHorizontal, + divider.dim, + renderCommands(loaded) ) - .padding(1) - ZIO.debug(view.render(80, 10)) - } -} + case State.Loading => + View.text("Loading...") + + View.vertical( + header(state), + body + ) + + override def update(state: State, input: KeyCode | Message): Handled = + state match + case State.Loading => + input match + case KeyCode.Exit | KeyCode.Character('q') => + Handled.Exit + + case Message.LoadDependencies(dependencies) => + Handled.Continue(State.Loaded(dependencies.length, 0, List.empty, Set.empty, 0)) + + case _ => Handled.Continue(state) + + case state: State.Loaded => + input match + case Message.LoadVersions(dep) => + Handled.Continue(state.withVersions(dep).incrementFinishedCount) + + case KeyCode.Up | Character('k') => + Handled.Continue(state.moveUp) + case KeyCode.Down | Character('j') => + Handled.Continue(state.moveDown) + case KeyCode.Right => + Handled.Continue(state.nextVersion) + case KeyCode.Left => + Handled.Continue(state.previousVersion) + case Character('g') => + Handled.Continue(state.copy(showGroup = !state.showGroup)) + case Character(' ') => + Handled.Continue(state.toggleCurrent) + case Character('a') => + Handled.Continue(state.toggleAll) + case KeyCode.Enter => + Handled.Done(state.selectedStates) + case KeyCode.Exit | Character('q') => + Handled.Exit + case _ => + Handled.Continue(state) + +object Main extends ZIOAppDefault: + + val dependencyStream: ZStream[DependencyLoader, Throwable, List[WithSource[Dependency]]] = + ZStream + .fromZIO { + ZIO.serviceWithZIO[DependencyLoader](_.getDependencies(".")) + } + + def loadVersionsStream( + dependencies: List[WithSource[Dependency]] + ): ZStream[Versions, Throwable, ScalaUpdateCLI.Message] = + ZStream.fromIterable(dependencies).mapZIO { dep => + ZIO.serviceWithZIO[Versions](_.getVersions(dep.value)).map { versions => + ScalaUpdateCLI.Message.LoadVersions(WithVersions(dep, versions)) + } + } + + val versionStream: ZStream[Versions & DependencyLoader, Throwable, ScalaUpdateCLI.Message] = + dependencyStream + .flatMap { dependencies => + ZStream.succeed(ScalaUpdateCLI.Message.LoadDependencies(dependencies)) merge + loadVersionsStream(dependencies) + } + + val program = + for + env <- ZIO.environment[Versions & DependencyLoader] + selected <- ScalaUpdateCLI.run( + ScalaUpdateCLI.State.Loading, + versionStream.provideEnvironment(env).orDie + ) + _ <- ZIO.foreachDiscard(selected) { states => + val versionWithSource = states.map { state => + WithSource(state.selectedVersion, state.sourceInfo) + } + + writeToSource(versionWithSource) + } + yield () + + private def writeToSource(selectedVersions: List[WithSource[Version]]): Task[Unit] = + val groupedBySourceFile = selectedVersions.groupBy(_.sourceInfo.path) + ZIO.foreachParDiscard(groupedBySourceFile) { case (path, versions) => + rewriteSourceFile(path, versions) + } + + private def rewriteSourceFile( + path: String, + versions: List[WithSource[Version]] + ): Task[Unit] = + for + sourceCode <- ZIO.readFile(path) + patches = versions.map { version => + Rewriter.Patch( + start = version.sourceInfo.start, + end = version.sourceInfo.end, + replacement = version.value.toString + ) + } + updatedSourceCode = Rewriter.rewrite(sourceCode, patches) + _ <- ZIO.writeFile(path, updatedSourceCode) + yield () + + val run = +// ZIO +// .serviceWithZIO[ScalaUpdate](_.updateAllDependencies(".")) + program + .provide( + ScalaUpdate.layer, + Versions.live, + DependencyLoader.live, + Files.live + ) diff --git a/src/main/scala/update/Replacement.scala b/src/main/scala/update/Replacement.scala deleted file mode 100644 index fec4f04..0000000 --- a/src/main/scala/update/Replacement.scala +++ /dev/null @@ -1,28 +0,0 @@ -package update - -import scala.annotation.tailrec -import scala.collection.mutable - -final case class Replacement(start: Int, end: Int, string: String) - -object Replacement { - - def replace(original: String, replacements: List[Replacement]): String = { - val builder = new mutable.StringBuilder - - @tailrec - def loop(sorted: List[Replacement], i: Int): String = - sorted match { - case replacement :: tail => - builder.append(original.substring(i, replacement.start)) - builder.append(replacement.string) - loop(tail, replacement.end) - case Nil => - builder.append(original.substring(i)) - builder.toString - } - - loop(replacements.sortBy(_.start).distinctBy(_.start), 0) - } - -} diff --git a/src/main/scala/update/UpdateOptions.scala b/src/main/scala/update/UpdateOptions.scala deleted file mode 100644 index 459a67c..0000000 --- a/src/main/scala/update/UpdateOptions.scala +++ /dev/null @@ -1,51 +0,0 @@ -package update - -final case class UpdateOptions( - major: Option[Version], - minor: Option[Version], - patch: Option[Version], - preRelease: Option[Version] -) { - def newestVersion: Option[Version] = - major.orElse(minor).orElse(patch).orElse(preRelease) - - def allVersions: List[Version] = major.toList ++ minor.toList ++ patch.toList ++ preRelease.toList - - def isEmpty: Boolean = major.isEmpty && minor.isEmpty && patch.isEmpty && preRelease.isEmpty - - def isNonEmpty: Boolean = !isEmpty -} - -object UpdateOptions { - - def getOptions(current: Version, available: List[Version]): UpdateOptions = { - val major = current.major - val minor = current.minor - val patch = current.patch - - val allNewerVersions = - available.filter(_.isNewerThan(current)).sorted - val majorVersion = - allNewerVersions - .filter(v => ((current.preRelease.isDefined && v.major == major) || v.major > major) && v.preRelease.isEmpty) - .lastOption - val minorVersion = - allNewerVersions - .filter(v => v.major == major && v.minor > minor && v.preRelease.isEmpty) - .lastOption - .filterNot(majorVersion.contains) - val patchVersion = allNewerVersions - .filter(v => v.major == major && v.minor == minor && v.patch > patch && v.preRelease.isEmpty) - .lastOption - .filterNot(v => majorVersion.contains(v) || minorVersion.contains(v)) - val preReleaseVersions = - allNewerVersions - .filter(_.preRelease.isDefined) - // TODO: Filter out if the newest major version is newer than the pre-release version - .filterNot(v => majorVersion.exists(_.isNewerThan(v))) - .lastOption - - UpdateOptions(majorVersion, minorVersion, patchVersion, preReleaseVersions) - } - -} diff --git a/src/main/scala/update/cli/CliApp.scala b/src/main/scala/update/cli/CliApp.scala deleted file mode 100644 index 37f334f..0000000 --- a/src/main/scala/update/cli/CliApp.scala +++ /dev/null @@ -1,265 +0,0 @@ -package update.cli - -import update._ -import tui.TerminalApp.Step -import tui._ -import view._ -import zio._ - -sealed trait VersionType extends Product with Serializable - -object VersionType { - case object Major extends VersionType - case object Minor extends VersionType - case object Patch extends VersionType - case object PreRelease extends VersionType -} - -final case class DependencyState( - location: Location, - dependencies: NonEmptyChunk[Dependency], - versions: NonEmptyChunk[(VersionType, Version)], - versionIndex: Int -) { - def selectedVersion: (VersionType, Version) = versions(versionIndex) - - def nextVersion: DependencyState = - copy(versionIndex = (versionIndex + 1) min (versions.size - 1)) - - def prevVersion: DependencyState = - copy(versionIndex = (versionIndex - 1) max 0) - -} - -object DependencyState { - def from(deps: NonEmptyChunk[DependencyWithLocation], options: UpdateOptions): DependencyState = { - val versions = - Chunk( - options.major.map(VersionType.Major -> _), - options.minor.map(VersionType.Minor -> _), - options.patch.map(VersionType.Patch -> _), - options.preRelease.map(VersionType.PreRelease -> _) - ).flatten - - DependencyState( - deps.head.location, - deps.map(_.dependency), - NonEmptyChunk.fromIterable(versions.head, versions.tail), - 0 - ) - } -} - -final case class CliState( - dependencies: Chunk[DependencyState], - cursorIndex: Int = 0, - selected: Set[Int] = Set.empty, - showGroups: Boolean = false -) { - - def toggleShowGroups: CliState = - copy(showGroups = !showGroups) - - def toggle: CliState = { - val newSelected = - if (selected(cursorIndex)) selected - cursorIndex - else selected + cursorIndex - copy(selected = newSelected) - } - - def toggleAll: CliState = { - val newSelected = - if (selected.isEmpty) dependencies.indices.toSet - else Set.empty[Int] - copy(selected = newSelected) - } - - def moveUp: CliState = - if (cursorIndex == 0) this - else copy(cursorIndex = cursorIndex - 1) - - def moveDown: CliState = - if (cursorIndex == dependencies.size - 1) this - else copy(cursorIndex = cursorIndex + 1) - - def nextVersion: CliState = { - val newDependencies = dependencies.updated(cursorIndex, dependencies(cursorIndex).nextVersion) - copy(dependencies = newDependencies) - } - - def prevVersion: CliState = { - val newDependencies = dependencies.updated(cursorIndex, dependencies(cursorIndex).prevVersion) - copy(dependencies = newDependencies) - } - -} - -object CliApp extends TerminalApp[Nothing, CliState, Chunk[(DependencyWithLocation, Version)]] { - override def render(state: CliState): View = { - val longestGroupLength = state.dependencies.flatMap(_.dependencies.map(_.group.value.length)).max - val longestArtifactLength = state.dependencies.flatMap(_.dependencies.map(_.artifact.value.length)).max - val longestVersionLength = state.dependencies.flatMap(_.dependencies.map(_.version.value.length)).max - val dependencies = state.dependencies.zipWithIndex.map { case (dep, idx) => - val selected: View = - if (state.selected.contains(idx)) { - View.text("▣").green - } else { - View.text("☐").cyan.dim - } - - val isActive = idx == state.cursorIndex - - val cursor: View = - if (isActive) { - View.text("❯").cyan - } else { - View.text(" ") - } - - val versions = dep.versions.toChunk.zipWithIndex.flatMap { case ((_, v), versionIdx) => - Chunk( - if (versionIdx == dep.versionIndex && state.selected.contains(idx)) { - View.text(v.value).green.underlined - } else if (versionIdx == dep.versionIndex) { - View.text(v.value).green - } else { - View.text(v.value).green.dim - } - ) - } - - val versionMode = - if (dep.versions.size > 1 && isActive) View.text(dep.selectedVersion._1.toString).yellow - else View.text("") - - val groupView = Chunk.from(Option.when(state.showGroups) { - View.horizontal( - View.text(dep.dependencies.head.group.value.padTo(longestGroupLength, ' ')).cyan, - View.text("%%").cyan.dim - ) - }) - - View.horizontal(1, VerticalAlignment.top)( - Chunk( - View.horizontal(0)(cursor, selected) - ) ++ groupView ++ - Chunk( - View.vertical( - dep.dependencies.map { dep => - View.text(dep.artifact.value.padTo(longestArtifactLength, ' ')).cyan - }: _* - ) - ) ++ - Chunk.from(Option.when(state.showGroups)(View.text("%").cyan.dim)) ++ - Chunk( - View.text(dep.dependencies.head.version.value.padTo(longestVersionLength, ' ')).cyan.dim, - View.text("→").cyan.dim, - View.horizontal((versions :+ versionMode): _*) - ): _* - ) - } - - val toggleKeybinding = - if (state.dependencies(state.cursorIndex).versions.size > 1) { - View.horizontal(0)( - " ", - View.text("←/→").blue, - " ", - View.text("toggle version").blue.dim - ) - } else { - View.text("") - } - - val confirmBinding = - if (state.selected.nonEmpty) { - View.horizontal(0)( - " ", - View.text("enter").blue, - " ", - View.text("update").blue.dim - ) - } else { - View.text("") - } - - val keybindings = - View - .horizontal(0)( - View.text("space").blue, - " ", - View.text("toggle").blue.dim, - " ", - View.text("a").blue, - " ", - View.text("toggle all").blue.dim, - " ", - View.text("↑/↓").blue, - " ", - View.text("move up/down").blue.dim, - toggleKeybinding, - confirmBinding, - " ", - View.text("g").blue, - " ", - View.text(if (state.showGroups) "hide groups" else "show groups").blue.dim, - " ", - View.text("q").blue, - " ", - View.text("quit").blue.dim - ) - .padding(top = 1) - - View - .vertical( - Chunk( - View.text("SCALA INTERACTIVE UPDATE").blue, - View.text("────────────────────────").blue.dim - ) ++ - dependencies ++ - Chunk( - keybindings - ): _* - ) - .padding(1) - } - - override def update( - state: CliState, - event: TerminalEvent[Nothing] - ): TerminalApp.Step[CliState, Chunk[(DependencyWithLocation, Version)]] = - event match { - case TerminalEvent.UserEvent(_) => - ??? - case TerminalEvent.SystemEvent(keyEvent) => - keyEvent match { - case KeyEvent.Character(' ') => - Step.update(state.toggle) - case KeyEvent.Character('a') => - Step.update(state.toggleAll) - case KeyEvent.Enter => - val chosen: List[(DependencyWithLocation, Version)] = - state.selected.toList.sorted.flatMap { idx => - val deps = state.dependencies(idx) - deps.dependencies.map { dep => - (DependencyWithLocation(dep, deps.location), deps.selectedVersion._2) - } - } - Step.succeed(Chunk.from(chosen)) - case KeyEvent.Up | KeyEvent.Character('k') => - Step.update(state.moveUp) - case KeyEvent.Down | KeyEvent.Character('j') => - Step.update(state.moveDown) - case KeyEvent.Character('g') => - Step.update(state.toggleShowGroups) - case KeyEvent.Right => - Step.update(state.nextVersion) - case KeyEvent.Left => - Step.update(state.prevVersion) - case KeyEvent.Escape | KeyEvent.Exit | KeyEvent.Character('q') => - Step.succeed(Chunk.empty) - case _ => - Step.update(state) - } - } -} diff --git a/src/main/scala/update/model/Dependency.scala b/src/main/scala/update/model/Dependency.scala new file mode 100644 index 0000000..643c866 --- /dev/null +++ b/src/main/scala/update/model/Dependency.scala @@ -0,0 +1,31 @@ +package update.model + +final case class Group(value: String) extends AnyVal +final case class Artifact(value: String) extends AnyVal + +// group %% artifact % version +final case class Dependency( + group: Group, + artifact: Artifact, + version: Version, + isJava: Boolean = true +) + +object Dependency: + def apply(group: String, artifact: String, version: String, isJava: Boolean): Dependency = + new Dependency(Group(group), Artifact(artifact), Version(version), isJava) + + def scalaVersion(versionString: String): Dependency = + val version = Version(versionString) + if version.majorVersion.contains(3) + then Dependency(Group("org.scala-lang"), Artifact("scala3-library"), version) + else Dependency(Group("org.scala-lang"), Artifact("scala-library"), version) + + implicit val dependencyOrder: Ordering[Dependency] = + Ordering.by(d => (d.group.value, d.artifact.value, d.version)) + + def sbt(version: String): Dependency = Dependency(sbtGroup, sbtArtifact, Version(version)) + + private val sbtGroup: Group = Group("org.scala-sbt") + private val sbtArtifact: Artifact = Artifact("sbt") + def isSbt(group: Group, artifact: Artifact): Boolean = group == sbtGroup && artifact == sbtArtifact diff --git a/src/main/scala/update/model/PreRelease.scala b/src/main/scala/update/model/PreRelease.scala new file mode 100644 index 0000000..fc94508 --- /dev/null +++ b/src/main/scala/update/model/PreRelease.scala @@ -0,0 +1,51 @@ +package update.model + +import scala.math.Ordered.orderingToOrdered + +enum PreRelease: + case RC(n: Int) extends PreRelease + case M(n: Int) extends PreRelease + case Alpha(n: Option[Int]) extends PreRelease + case Beta(n: Option[Int]) extends PreRelease + + override def toString: String = this match + case RC(n) => s"RC$n" + case M(n) => s"M$n" + case Alpha(Some(n)) => s"alpha.$n" + case Alpha(None) => "alpha" + case Beta(Some(n)) => s"beta.$n" + case Beta(None) => "beta" + +object PreRelease: + import Version.MatchInt + + // alpha < beta < M < RC + def compare(x: PreRelease, y: PreRelease): Int = + def ordinal = (p: PreRelease) => + p match + case PreRelease.RC(_) => 4 + case PreRelease.M(_) => 3 + case PreRelease.Beta(_) => 2 + case PreRelease.Alpha(_) => 1 + + def number(p: PreRelease): Int = p match + case PreRelease.RC(n) => n + case PreRelease.M(n) => n + case PreRelease.Beta(Some(n)) => n + case PreRelease.Alpha(Some(n)) => n + case _ => 0 + + (ordinal(x), number(x)) compare (ordinal(y), number(y)) + + def parse(value: String): Option[PreRelease] = + val Re = raw"([A-Za-z]+)(\d+)(\w+)?".r + value match + case Re("RC", n, _) => Some(RC(n.toInt)) + case Re("M", n, _) => Some(M(n.toInt)) + case "alpha" => Some(Alpha(None)) + case s"alpha.${MatchInt(n)}" => Some(Alpha(Some(n))) + case "beta" => Some(Beta(None)) + case s"beta.${MatchInt(n)}" => Some(Beta(Some(n))) + case _ => None + + given ordering: Ordering[PreRelease] = (x: PreRelease, y: PreRelease) => PreRelease.compare(x, y) diff --git a/src/main/scala/update/model/UpdateOptions.scala b/src/main/scala/update/model/UpdateOptions.scala new file mode 100644 index 0000000..59cca3f --- /dev/null +++ b/src/main/scala/update/model/UpdateOptions.scala @@ -0,0 +1,80 @@ +package update.model +import Ordered.given + +enum VersionType: + case Major + case Minor + case Patch + case PreRelease + + // wrap around + def next: VersionType = VersionType.fromOrdinal((ordinal + 1) % VersionType.values.length) + def prev: VersionType = VersionType.fromOrdinal((ordinal - 1 + VersionType.values.length) % VersionType.values.length) + +final case class UpdateOptions( + major: Option[Version], + minor: Option[Version], + patch: Option[Version], + preRelease: Option[Version] +): + + def hasMajor: Boolean = major.isDefined + def hasMinor: Boolean = minor.isDefined + def hasPatch: Boolean = patch.isDefined + def hasPreRelease: Boolean = preRelease.isDefined + def hasVersionType(versionType: VersionType): Boolean = + versionType match + case VersionType.Major => hasMajor + case VersionType.Minor => hasMinor + case VersionType.Patch => hasPatch + case VersionType.PreRelease => hasPreRelease + + def newestVersion: Option[Version] = + major.orElse(minor).orElse(patch).orElse(preRelease) + + def allVersions: List[Version] = major.toList ++ minor.toList ++ patch.toList ++ preRelease.toList + + def isEmpty: Boolean = major.isEmpty && minor.isEmpty && patch.isEmpty && preRelease.isEmpty + + def isNonEmpty: Boolean = !isEmpty + +object UpdateOptions: + + def getOptions(current: Version, available0: List[Version]): UpdateOptions = + current match + case v: Version.SemVer => getOptions(v, available0) + case _ => UpdateOptions(None, None, None, None) + + def getOptions(current: Version.SemVer, available0: List[Version]): UpdateOptions = + val available = available0.collect { case v: Version.SemVer => v } + val major = current.major + val minor = current.minor + val patch = current.patch + + val allNewerVersions = available.filter(_ > current).sorted + + val majorVersion = allNewerVersions + .filter(v => ((current.preRelease.isDefined && v.major == major) || v.major > major) && v.preRelease.isEmpty) + .lastOption + + val minorVersion = allNewerVersions + .filter(v => v.major == major && v.minor > minor && v.preRelease.isEmpty) + .lastOption + .filterNot(majorVersion.contains) + + val patchVersion = allNewerVersions + .filter(v => v.major == major && v.minor == minor && v.patch > patch && v.preRelease.isEmpty) + .lastOption + .filterNot(v => majorVersion.contains(v) || minorVersion.contains(v)) + + val preReleaseVersions = + allNewerVersions + .filter(_.preRelease.isDefined) + .filterNot { version => + patchVersion.exists(_ >= version) || + minorVersion.exists(_ >= version) || + majorVersion.exists(_ >= version) + } + .lastOption + + UpdateOptions(majorVersion, minorVersion, patchVersion, preReleaseVersions) diff --git a/src/main/scala/update/model/Version.scala b/src/main/scala/update/model/Version.scala new file mode 100644 index 0000000..d5f080a --- /dev/null +++ b/src/main/scala/update/model/Version.scala @@ -0,0 +1,73 @@ +package update.model + +import scala.math.Ordered.orderingToOrdered + +enum Version: + case SemVer(major: Int, minor: Int, patch: Int, preRelease: Option[PreRelease]) + case Other(value: String) + + def majorVersion: Option[Int] = this match + case SemVer(major, _, _, _) => Some(major) + case Other(_) => None + + override def toString: String = this match + case SemVer(major, minor, patch, preRelease) => + val preReleaseStr = preRelease.fold("")(pr => s"-$pr") + s"$major.$minor.$patch$preReleaseStr" + case Other(value) => value + + def isPreRelease: Boolean = this match + case SemVer(_, _, _, Some(_)) => true + case Other(_) => true + case _ => false + +object Version: + + object MatchInt: + def unapply(value: String): Option[Int] = + value.toIntOption + + object MatchPreRelease: + def unapply(value: String): Option[PreRelease] = + PreRelease.parse(value) + + def apply(string: String): Version = + string match + // 1.1.1 + case s"${MatchInt(major)}.${MatchInt(minor)}.${MatchInt(patch)}" => + SemVer(major, minor, patch, None) + + // 1.1.1-RC1 + case s"${MatchInt(major)}.${MatchInt(minor)}.${MatchInt(patch)}-${MatchPreRelease(preRelease)}" => + SemVer(major, minor, patch, Some(preRelease)) + + // 1.1 + case s"${MatchInt(major)}.${MatchInt(minor)}" => + SemVer(major, minor, 0, None) + + // 2.0-RC1 + case s"${MatchInt(major)}.${MatchInt(minor)}-${MatchPreRelease(preRelease)}" => + SemVer(major, minor, 0, Some(preRelease)) + + case other => + Other(other) + + // None should be considered greater than any pre-release + given Ordering[Option[PreRelease]] = + new Ordering[Option[PreRelease]]: + def compare(x: Option[PreRelease], y: Option[PreRelease]): Int = + (x, y) match + case (Some(_), None) => -1 + case (None, Some(_)) => 1 + case (Some(pr1), Some(pr2)) => pr1 compare pr2 + case (None, None) => 0 + + given Ordering[Version] with + def compare(x: Version, y: Version): Int = + (x, y) match + case (SemVer(m1, n1, p1, pr1), SemVer(m2, n2, p2, pr2)) => + summon[Ordering[(Int, Int, Int, Option[PreRelease])]] + .compare((m1, n1, p1, pr1), (m2, n2, p2, pr2)) + case (SemVer(_, _, _, _), Other(_)) => -1 + case (Other(_), SemVer(_, _, _, _)) => 1 + case (Other(x), Other(y)) => x compare y diff --git a/src/main/scala/update/model/WithVersions.scala b/src/main/scala/update/model/WithVersions.scala new file mode 100644 index 0000000..c1da7af --- /dev/null +++ b/src/main/scala/update/model/WithVersions.scala @@ -0,0 +1,3 @@ +package update.model + +final case class WithVersions[A](value: A, versions: List[Version]) diff --git a/src/main/scala/update/search/Search.scala b/src/main/scala/update/search/Search.scala deleted file mode 100644 index c657df0..0000000 --- a/src/main/scala/update/search/Search.scala +++ /dev/null @@ -1,151 +0,0 @@ -package update.search - -import update.{Artifact, Group, Version} -import tui.view.View -import zio._ -import zio.json._ - -import java.net.URLEncoder -import java.time.Instant -import scala.annotation.tailrec - -final case class Payload( - response: Response -) - -object Payload { - implicit val codec: JsonCodec[Payload] = - DeriveJsonCodec.gen[Payload] -} - -final case class Response( - numFound: Int, - start: Int, - docs: List[Doc] -) - -object Response { - implicit val codec: JsonCodec[Response] = - DeriveJsonCodec.gen[Response] -} - -final case class Doc( - id: String, - g: String, - a: String, - latestVersion: String, - timestamp: Long -) - -final case class SearchResult( - group: Group, - artifact: Artifact, - scalaVersions: List[String], - latestVersion: Version, - lastUpdated: Instant -) { - def render: View = - View.horizontal( - View.text(s"\"${group.value}\"").cyan, - View.text("%%").cyan.dim, - View.text(s"\"${artifact.value}\"").cyan, - View.text("%").cyan.dim, - View.text(s"\"${latestVersion.value}\"").cyan - ) -} - -object SearchResult { - def fromDoc(doc: Doc): SearchResult = { - val (artifact, scalaVersion) = - doc.a match { - case s"${artifact}_sjs1_2.11" => - (artifact, "sjs1_2.11") - case s"${artifact}_sjs1_2.12" => - (artifact, "sjs1_2.12") - case s"${artifact}_sjs1_2.13" => - (artifact, "sjs1_2.13") - case s"${artifact}_sjs1_3" => - (artifact, "sjs1_3") - case s"${artifact}_2.11" => - (artifact, "2.11") - case s"${artifact}_2.12" => - (artifact, "2.12") - case s"${artifact}_2.13" => - (artifact, "2.13") - case s"${artifact}_3" => - (artifact, "3") - case other => - (other, "OOPS") - } - - SearchResult( - Group(doc.g), - Artifact(artifact), - List(scalaVersion), - Version(doc.latestVersion), - Instant.ofEpochMilli(doc.timestamp) - ) - } - - // Combine results with same group, artifact and latestVersion but different scala versions - def combineResults(results: List[SearchResult]): List[SearchResult] = { - @tailrec - def loop( - results: List[SearchResult], - acc: List[SearchResult] - ): List[SearchResult] = - (results, acc) match { - case (Nil, acc) => acc.reverse - case (r :: rs, a :: as) - if a.group == r.group && a.artifact == r.artifact && a.latestVersion == r.latestVersion => - loop(rs, a.copy(scalaVersions = a.scalaVersions ++ r.scalaVersions) :: as) - case (r :: rs, acc) => - loop(rs, r :: acc) - } - - loop(results, Nil) - } - -} - -object Doc { - implicit val codec: JsonCodec[Doc] = - DeriveJsonCodec.gen[Doc] -} - -final case class Search() { - - def search(query: String): Task[List[SearchResult]] = { - val urlEncodedQuery = URLEncoder.encode(query, "UTF-8") - val url = s"https://search.maven.org/solrsearch/select?q=$urlEncodedQuery&start=0&rows=60" - for { - string <- ZIO.attempt { - val source = scala.io.Source.fromURL(url) - val string = source.mkString - source.close() - string - } - payload <- ZIO.from(string.fromJson[Payload]).mapError(new Error(_)) - } yield SearchResult - .combineResults(payload.response.docs.map(SearchResult.fromDoc)) - .distinctBy(sr => (sr.group, sr.artifact, sr.latestVersion)) - } - - def searchCLI(query: String): Task[Unit] = - for { - results <- search(query) - _ <- ZIO.debug( - View - .vertical( - Chunk( - View.horizontal(View.text("MAVEN PACKAGES FOR").blue, View.text(query).blue.underlined), - View.text("─" * s"MAVEN PACKAGES FOR $query".length).blue.dim - ) ++ - results.map(_.render): _* - ) - .padding(1) - .renderNow - ) - } yield () - -} diff --git a/src/main/scala/update/services/Files.scala b/src/main/scala/update/services/Files.scala new file mode 100644 index 0000000..135f6b5 --- /dev/null +++ b/src/main/scala/update/services/Files.scala @@ -0,0 +1,31 @@ +package update.services + +import update.utils.FileUtils +import zio.* +import zio.nio.file.Path +import zio.stream.* + +trait Files: + def allBuildScalaPaths(path: String): Task[Chunk[Path]] + +object Files: + val live = ZLayer.succeed(FilesLive) + +case object FilesLive extends Files: + override def allBuildScalaPaths(root: String): Task[Chunk[Path]] = + val buildProperties = "build.properties" + + val rootPath = Path(root) + // Build files with Sbt1 dialect content + val projectScalaPaths = FileUtils.allScalaFiles(rootPath / "project") + val buildSbtPath = ZStream.succeed(rootPath / "build.sbt") + val buildMillPath = FileUtils.allMillFiles(rootPath) + val pluginsPath = ZStream.succeed(rootPath / "project" / "plugins.sbt") + // A .properties file + val sbtPropertiesFilePath = ZStream.succeed(rootPath / "project" / buildProperties) + // ++ sbtPropertiesFilePath + + val allSourcePaths = (projectScalaPaths ++ buildSbtPath ++ pluginsPath ++ buildMillPath) + .filterZIO(path => zio.nio.file.Files.exists(path)) + + allSourcePaths.runCollect diff --git a/src/main/scala/update/services/ScalaUpdate.scala b/src/main/scala/update/services/ScalaUpdate.scala new file mode 100644 index 0000000..6753992 --- /dev/null +++ b/src/main/scala/update/services/ScalaUpdate.scala @@ -0,0 +1,74 @@ +package update.services + +import update.model.* +import update.utils.Rewriter +import update.* +import update.services.dependencies.* +import zio.* + +final case class ScalaUpdate(dependencyLoader: DependencyLoader, versions: Versions): + + // - Find all dependencies with their current versions + // - Load all versions for each dependency + // - Find the most recent patch/minor/major/pre-release version for each dependency + // - THE USER will then select a version for each dependency (or not) + // - Gather all selected versions with their source positions + // - Group by source file + // - Rewrite each source file with the new versions + def updateAllDependencies(root: String): Task[List[WithVersions[WithSource[Dependency]]]] = + for + dependencies <- dependencyLoader.getDependencies(root) + withVersions <- ZIO.foreachPar(dependencies)(getVersionsForDependency) + _ <- ZIO.foreachDiscard(withVersions) { withVersions => + ZIO.succeed( + println( + s"${withVersions.value.value} in ${withVersions.value.sourceInfo.path}\n versions: ${withVersions.versions}" + ) + ) + } + latestVersions = withVersions.flatMap(getLatestVersion).distinct + _ <- ZIO.foreachDiscard(latestVersions) { version => + ZIO.succeed(println(s"${version.value} in ${version.sourceInfo.path}")) + } + _ <- writeToSource(latestVersions) + yield withVersions + + private def writeToSource(selectedVersions: List[WithSource[Version]]): Task[Unit] = + val groupedBySourceFile = selectedVersions.groupBy(_.sourceInfo.path) + ZIO.foreachParDiscard(groupedBySourceFile) { case (path, versions) => + rewriteSourceFile(path, versions) + } + + private def rewriteSourceFile( + path: String, + versions: List[WithSource[Version]] + ): Task[Unit] = + for + sourceCode <- ZIO.readFile(path) + patches = versions.map { version => + Rewriter.Patch( + start = version.sourceInfo.start, + end = version.sourceInfo.end, + replacement = version.value.toString + ) + } + updatedSourceCode = Rewriter.rewrite(sourceCode, patches) + _ <- ZIO.writeFile(path, updatedSourceCode) + yield () + + private def getLatestVersion(withVersions: WithVersions[WithSource[Dependency]]): Option[WithSource[Version]] = + withVersions.versions.filterNot(_.isPreRelease).maxOption.map { latest => + WithSource(latest, withVersions.value.sourceInfo) + } + + private def getVersionsForDependency( + dependency: WithSource[Dependency] + ): Task[WithVersions[WithSource[Dependency]]] = + for versions <- versions.getVersions(dependency.value) + yield WithVersions(dependency, versions) + +object ScalaUpdate: + val layer = ZLayer.fromFunction(ScalaUpdate.apply) + + def updateAllDependencies(root: String): RIO[ScalaUpdate, List[WithVersions[WithSource[Dependency]]]] = + ZIO.serviceWithZIO[ScalaUpdate](_.updateAllDependencies(root)) diff --git a/src/main/scala/update/services/Versions.scala b/src/main/scala/update/services/Versions.scala new file mode 100644 index 0000000..9c953a2 --- /dev/null +++ b/src/main/scala/update/services/Versions.scala @@ -0,0 +1,89 @@ +package update.services + +import coursierapi.Complete +import update.model.* +import zio.* + +import scala.jdk.CollectionConverters.* + +trait Versions: + def getVersions(group: Group, artifact: Artifact, isJava: Boolean): Task[List[Version]] + + def getVersions(dependency: Dependency): Task[List[Version]] = + getVersions(dependency.group, dependency.artifact, dependency.isJava) + +object Versions: + val live = ZLayer.fromFunction(() => VersionsLive()) + +final case class VersionsLive() extends Versions: + val cache = coursierapi.Cache.create() + + def getVersions(group: Group, artifact: Artifact, isJava: Boolean): Task[List[Version]] = + val coursierVersions = coursierapi.Versions.create().withCache(cache) + val coursierComplete = Complete.create() + ZIO.attemptBlocking { + + // If %% is used, we need to expand the artifact to include the scala version + // so we can use Coursier's completion API to get the full list of artifacts + // where the scala version is included in the artifact name, e.g. cats-core_2.13. + // Otherwise, for "isJava" artifacts, we can just use the artifact name as is + val expandedArtifacts = + Option( + coursierComplete + .withInput(s"${group.value}:${artifact.value}_") + .complete() + .getCompletions + .asScala + ).filter(_.nonEmpty) + .getOrElse(List(artifact.value)) + + val versions = + expandedArtifacts.toList.flatMap { artifact => + coursierVersions + .withModule(coursierapi.Module.of(group.value, artifact)) + .versions() + .getMergedListings + .getAvailable + .asScala + .map(v => Version(v)) + .toList + }.distinct + + versions + } + +final case class VersionsInMemory(versions: Map[(Group, Artifact), List[Version]]) extends Versions: + override def getVersions(group: Group, artifact: Artifact, isJava: Boolean): Task[List[Version]] = + ZIO.succeed(versions.getOrElse((group, artifact), Nil)) + +object VersionsInMemory: + def layer(versions: Map[(Group, Artifact), List[Version]]): ULayer[Versions] = + ZLayer.succeed(VersionsInMemory(versions)) + +//object TestVersions extends ZIOAppDefault: +// +// val tests = List( +//// Dependency(Group("dev.zio"), Artifact("zio"), Version("1.0.0")), +//// Dependency(Group("org.typelevel"), Artifact("cats-core"), Version("2.0.0")), +//// Dependency(Group("com.github.sbt"), Artifact("sbt-native-packager"), Version("1.9.11")), +//// Dependency(Group("com.github.sbt"), Artifact("sbt-ci-release"), Version("1.5.11")), +//// Dependency(Group("ch.epfl.scala"), Artifact("sbt-scalafix"), Version("0.10.4")), +//// Dependency(Group("org.scalameta"), Artifact("sbt-scalafmt"), Version("0.10.4")), +//// Dependency(Group("org.scala-sbt"), Artifact("sbt"), Version("1.5.5")), +// // "org.scala-lang" %% "scala3-compiler" % "0.5.4" +//// Dependency(Group("org.scala-lang"), Artifact("scala3-compiler"), Version("0.5.4")), +// // io.get-coursier +//// Dependency(Group("io.get-coursier"), Artifact("interface"), Version("2.0.0-RC6")) +//// com.github.sbt % sbt-native-packager +// Dependency(Group("com.github.sbt"), Artifact("sbt-native-packager"), Version("1.9.11")) +// // postgres +//// Dependency(Group("org.postgresql"), Artifact("postgresql"), Version("42.2.23")) +// ) +// +// def run = +// ZIO.foreach(tests) { dependency => +// VersionsLive() +// .getVersions(dependency) +// .map(_.mkString(", ")) +// .debug(s"${dependency.group}:${dependency.artifact}") +// } diff --git a/src/main/scala/update/services/dependencies/DependencyLoader.scala b/src/main/scala/update/services/dependencies/DependencyLoader.scala new file mode 100644 index 0000000..3f279a3 --- /dev/null +++ b/src/main/scala/update/services/dependencies/DependencyLoader.scala @@ -0,0 +1,18 @@ +package update.services.dependencies + +import update.model.Dependency +import update.services.* +import zio.* + +object DependencyLoader: + val live: ZLayer[Files, Nothing, DependencyLoader] = + for + sbtLoader <- DependencyLoaderSbtVersion.layer + scalaLoader <- DependencyLoaderScalaSources.layer + yield ZEnvironment(DependencyLoaderCombined(List(sbtLoader.get, scalaLoader.get))) + + def getDependencies(root: String): ZIO[DependencyLoader, Throwable, List[WithSource[Dependency]]] = + ZIO.serviceWithZIO(_.getDependencies(root)) + +trait DependencyLoader: + def getDependencies(root: String): Task[List[WithSource[Dependency]]] diff --git a/src/main/scala/update/services/dependencies/DependencyLoaderCombined.scala b/src/main/scala/update/services/dependencies/DependencyLoaderCombined.scala new file mode 100644 index 0000000..af08f06 --- /dev/null +++ b/src/main/scala/update/services/dependencies/DependencyLoaderCombined.scala @@ -0,0 +1,11 @@ +package update.services.dependencies + +import update.model.Dependency +import zio.* + +final case class DependencyLoaderCombined( + loaders: List[DependencyLoader] +) extends DependencyLoader: + def getDependencies(root: String): Task[List[WithSource[Dependency]]] = + for dependencies <- ZIO.foreachPar(loaders)(_.getDependencies(root)) + yield dependencies.flatten diff --git a/src/main/scala/update/services/dependencies/DependencyLoaderSbtVersion.scala b/src/main/scala/update/services/dependencies/DependencyLoaderSbtVersion.scala new file mode 100644 index 0000000..049b6cf --- /dev/null +++ b/src/main/scala/update/services/dependencies/DependencyLoaderSbtVersion.scala @@ -0,0 +1,27 @@ +package update.services.dependencies + +import update.model.* +import zio.* + +final case class DependencyLoaderSbtVersion() extends DependencyLoader: + private val regex = """sbt.version\s*=\s*([\d\.]+)""".r + + // look in the project/build.properties file for the sbt version + def getDependencies(root: String): Task[List[WithSource[Dependency]]] = { + for + buildProperties <- ZIO + .readFile(root + "/project/build.properties") + .option + .some + versionMatch <- ZIO.fromOption(regex.findFirstMatchIn(buildProperties)) + sourceInfo = SourceInfo( + root + "/project/build.properties", + versionMatch.start(1), + versionMatch.end(1) + ) + version = Version(versionMatch.group(1)) + yield WithSource(Dependency(Group("org.scala-sbt"), Artifact("sbt"), version, true), sourceInfo) + }.unsome.map(_.toList) + +object DependencyLoaderSbtVersion: + val layer = ZLayer.succeed(DependencyLoaderSbtVersion()) diff --git a/src/main/scala/update/services/dependencies/DependencyLoaderScalaSources.scala b/src/main/scala/update/services/dependencies/DependencyLoaderScalaSources.scala new file mode 100644 index 0000000..0180011 --- /dev/null +++ b/src/main/scala/update/services/dependencies/DependencyLoaderScalaSources.scala @@ -0,0 +1,58 @@ +package update.services.dependencies + +import coursierapi.{Cache, Fetch} +import dotty.tools.dotc.ast.Trees.{Tree, Untyped} +import dotty.tools.dotc.ast.untpd +import dotty.tools.dotc.core.Contexts.Context +import dotty.tools.dotc.util.SourceFile +import dotty.tools.io.AbstractFile +import update.model.Dependency +import update.services.Files +import update.utils.ParsingDriver +import zio.* +import zio.nio.file.Path + +import java.io.File +import scala.collection.mutable.ListBuffer +import scala.io.Codec +import scala.jdk.CollectionConverters.* + +/** This class uses a parser-only instance of the Dotty compiler to parse Scala + * build sources (.sbt files, project/ files, mill's .sc files, etc) and + * extract dependencies from them. + */ +final case class DependencyLoaderScalaSources(files: Files) extends DependencyLoader: + private val fetch = Fetch.create().withCache(Cache.create()) + fetch.addDependencies(coursierapi.Dependency.of("org.scala-lang", "scala3-library_3", "3.4.0")) + private val extraLibraries = fetch.fetch().asScala.map(_.toPath()).toSeq + private val driver = new ParsingDriver( + List("-color:never", "-classpath", extraLibraries.mkString(File.pathSeparator)) + ) + private given ctx: Context = driver.currentCtx + + def getDependencies(root: String): Task[List[WithSource[Dependency]]] = + for paths <- files.allBuildScalaPaths(root) + yield + val allTrees = ListBuffer.empty[untpd.Tree] + paths.foreach { path => + loadPath(path) + val trees: List[Tree[Untyped]] = driver.currentCtx.run.units.map(_.untpdTree) + allTrees ++= trees + } + DependencyTraverser.getDependencies(allTrees.toList) + + private def loadPath(path: Path): Unit = + val file = AbstractFile.getFile(path.toFile.toPath) + var contents = new String(file.toByteArray, Codec.UTF8.charSet) + + // NOTE: Until I figure out how to allow toplevel definitions, + // I'm wrapping the contents in a $$wrapper object. + val pathString = path.toString + if pathString.endsWith(".sbt") || pathString.endsWith(".sc") then contents = s"""object $$wrapper {\n$contents\n}""" + + val sourceFile = SourceFile.virtual(path.toFile.toURI.toString, contents) + val diagnostics = driver.run(path.toFile.toURI, sourceFile) + diagnostics.foreach(println) + +object DependencyLoaderScalaSources: + val layer = ZLayer.fromFunction(DependencyLoaderScalaSources.apply) diff --git a/src/main/scala/update/services/dependencies/DependencyTraverser.scala b/src/main/scala/update/services/dependencies/DependencyTraverser.scala new file mode 100644 index 0000000..54d6f63 --- /dev/null +++ b/src/main/scala/update/services/dependencies/DependencyTraverser.scala @@ -0,0 +1,160 @@ +package update.services.dependencies + +import dotty.tools.dotc.ast.{Trees, untpd} +import dotty.tools.dotc.ast.untpd.UntypedTreeTraverser +import dotty.tools.dotc.core.Constants.Constant +import dotty.tools.dotc.core.Contexts.Context +import update.* +import update.model.Dependency +import update.services.* + +import scala.collection.mutable +import scala.collection.mutable.ListBuffer + +object DependencyTraverser: + + def getDependencies(trees: List[untpd.Tree])(using Context): List[WithSource[Dependency]] = + traverseValDefs(trees) + traverseDependencies(trees) + dependencies.toList + + // Stores parsed version definitions, e.g.: + // val zioVersion = "version" + private val defs: mutable.Map[String, (String, untpd.Tree)] = mutable.Map.empty + + private val dependencies: ListBuffer[WithSource[Dependency]] = ListBuffer.empty + + private def traverseValDefs(trees: List[untpd.Tree])(using Context): Unit = + val traverser = new UntypedTreeTraverser: + override def traverse(tree: untpd.Tree)(using Context): Unit = + tree match + // Matches: val ident = "version" + case untpd.ValDef(Name(name), _, tree @ StringLiteral(version)) => + defs += (name -> (version, tree)) + + // Matches: def ident = "version" + case untpd.DefDef(Name(name), _, _, tree @ StringLiteral(version)) => + defs += (name -> (version, tree)) + + case _ => + traverseChildren(tree) + trees.foreach(traverser.traverse) + + private def traverseDependencies(trees: List[untpd.Tree])(using Context): Unit = + val traverser = new UntypedTreeTraverser: + override def traverse(tree: untpd.Tree)(using Context): Unit = + tree match + // Matches: scalaVersion := "3.4.0" + case MatchScalaVersion(versionTree @ StringLiteral(version)) => + println(s"scala version = $version") + dependencies += WithSource.fromTree(Dependency.scalaVersion(version), versionTree) + + // Matches: "groupId" %% "artifactId" % "version" + case MatchDependency(group, artifact, versionTree @ StringLiteral(version), isJava) => + dependencies += WithSource.fromTree(Dependency(group, artifact, version, isJava), versionTree) + + // Matches: "groupId" %% "artifactId" % ident + case MatchDependency(group, artifact, SelectOrIdent(ident), isJava) => + defs.get(ident).foreach { (version, tree) => + dependencies += WithSource.fromTree(Dependency(group, artifact, version, isJava), tree) + } + + // ivy"dev.zio::zio:$zioVersion", + case untpd.InterpolatedString( + _, + List( + Trees.Thicket(List(StringLiteral(MillGroupArtifact(group, artifact, colons)), SelectOrIdent(ident))), + _ + ) + ) => + defs.get(ident).foreach { (version, tree) => + dependencies += WithSource.fromTree(Dependency(group, artifact, version, colons == 1), tree) + } + + case untpd.InterpolatedString( + _, + List(tree @ StringLiteral(MillGroupArtifactVersion(group, artifact, version, colons))) + ) => + val withSource = WithSource.fromTree(Dependency(group, artifact, version, colons == 1), tree) + + // We want the position of the version, so we shift it by the length of the group + artifact + colons + // group::artifact:version + // >>>>>>>>>>>>>>>> + dependencies += withSource.shiftStart(group.length + artifact.length + colons).shiftEnd(1) + + case tree => +// if tree.toString.contains("scalaVersion") then println(s"tree = ${tree}") + traverseChildren(tree) + trees.foreach(traverser.traverse) + +//////////////// +// Extractors // +//////////////// + +// Matches: group::artifact or group:artifact and returns the # of colons as well +object MillGroupArtifact: + def unapply(string: String): Option[(String, String, Int)] = + string match + case s"$group::$artifact:" => Some((group, artifact, 2)) + case s"$group:$artifact:" => Some((group, artifact, 1)) + case s"$group:::$artifact:" => Some((group, artifact, 3)) + case _ => None + +object MillGroupArtifactVersion: + def unapply(string: String): Option[(String, String, String, Int)] = + string match + case s"$group::$artifact:$version" => Some((group, artifact, version, 2)) + case s"$group:$artifact:$version" => Some((group, artifact, version, 1)) + case s"$group:::$artifact:$version" => Some((group, artifact, version, 3)) + case _ => None + +// Matches either lhs. or just +object SelectOrIdent: + def unapply(tree: untpd.Tree)(using Context): Option[String] = tree match + case block: Trees.Block[?] if block.stats.isEmpty => SelectOrIdent.unapply(block.expr) + case untpd.Select(_, Name(name)) => Some(name) + case untpd.Ident(Name(name)) => Some(name) + case _ => None + +// match scala version assignment +// matches: scalaVersion := "3.4.0" +object MatchScalaVersion: + def unapply(tree: untpd.Tree)(using Context): Option[untpd.Tree] = + tree match + case untpd.InfixOp(Ident("scalaVersion"), Ident(":="), versionTree) => + Some(versionTree) + case untpd.InfixOp( + untpd.InfixOp(Ident("ThisBuild"), Ident("/"), Ident("scalaVersion")), + Ident(":="), + versionTree + ) => + Some(versionTree) + case _ => + None + +// matcher for: "groupId" %% "artifactId" % tree +object MatchDependency: + def unapply(tree: untpd.Tree)(using Context): Option[(String, String, untpd.Tree, Boolean)] = + tree match + case untpd.InfixOp( + untpd.InfixOp(StringLiteral(group), Ident(percents @ ("%%%" | "%%" | "%")), StringLiteral(artifact)), + Ident("%"), + version + ) => + val isJava = percents == "%" + Some((group, artifact, version, isJava)) + case _ => None + +object Name: + def unapply(name: dotty.tools.dotc.core.Names.Name): Some[String] = + Some(name.toString) + +object Ident: + def unapply(tree: untpd.Tree): Option[String] = tree match + case untpd.Ident(Name(name)) => Some(name) + case _ => None + +object StringLiteral: + def unapply(tree: untpd.Tree): Option[String] = tree match + case untpd.Literal(constant: Constant) => Some(constant.stringValue) + case _ => None diff --git a/src/main/scala/update/services/dependencies/WithSource.scala b/src/main/scala/update/services/dependencies/WithSource.scala new file mode 100644 index 0000000..990aac8 --- /dev/null +++ b/src/main/scala/update/services/dependencies/WithSource.scala @@ -0,0 +1,32 @@ +package update.services.dependencies + +import dotty.tools.dotc.ast.Trees.Tree +import dotty.tools.dotc.ast.untpd +import dotty.tools.dotc.core.Contexts.Context + +final case class SourceInfo(path: String, start: Int, end: Int): + def shiftStart(by: Int): SourceInfo = copy(start = start + by) + def shiftEnd(by: Int): SourceInfo = copy(end = end + by) + +final case class WithSource[A](value: A, sourceInfo: SourceInfo): + def start: Int = sourceInfo.start + def end: Int = sourceInfo.end + def path: String = sourceInfo.path + + def shiftStart(by: Int): WithSource[A] = copy(sourceInfo = sourceInfo.shiftStart(by)) + def shiftEnd(by: Int): WithSource[A] = copy(sourceInfo = sourceInfo.shiftEnd(by)) + +object WithSource: + def fromTree[A](value: A, tree: untpd.Tree)(using Context): WithSource[A] = + val path = tree.source.path + // If the file allows top-level definitions (e.g., an .sbt or a .sc), then we need to + // account for the addition of the `object $wrapper {\n` that's done in DependencyLoader.scala + val shift = if path.endsWith(".sbt") || path.endsWith(".sc") then 18 else 0 + WithSource( + value = value, + sourceInfo = SourceInfo( + path = path.stripPrefix("file:"), + start = tree.sourcePos.span.start - shift + 1, // shift over by a 1 to account for the double quote char + end = tree.sourcePos.span.end - shift - 1 + ) + ) diff --git a/src/main/scala/update/FileUtils.scala b/src/main/scala/update/utils/FileUtils.scala similarity index 71% rename from src/main/scala/update/FileUtils.scala rename to src/main/scala/update/utils/FileUtils.scala index e0888cf..7e8275d 100644 --- a/src/main/scala/update/FileUtils.scala +++ b/src/main/scala/update/utils/FileUtils.scala @@ -1,27 +1,24 @@ -package update +package update.utils import zio.nio.file.{Files, Path} import zio.stream.ZStream import java.io.IOException -object FileUtils { +object FileUtils: - /** - * Returns a Stream of all Scala - */ + /** Returns a Stream of all Scala + */ def allScalaFiles(path: Path): ZStream[Any, IOException, Path] = allFilesWithExt(path, ".scala") - /** - * Returns a Stream of all mill build file (ammonite script) - */ + /** Returns a Stream of all mill build file (ammonite script) + */ def allMillFiles(path: Path): ZStream[Any, IOException, Path] = allFilesWithExt(path, ".sc") - /** - * Returns file extension of given path, if it exists - */ + /** Returns file extension of given path, if it exists + */ def extension(path: Path): Option[String] = path.filename.toString().split('.').lastOption @@ -30,12 +27,11 @@ object FileUtils { Files .newDirectoryStream(path) .flatMap { path => - for { + for isDir <- ZStream.fromZIO(Files.isDirectory(path)) - res <- if (isDir) allScalaFiles(path) + res <- if isDir then allScalaFiles(path) else ZStream.succeed(path) - } yield res + yield res } .filter(_.toString.endsWith(extension)) } -} diff --git a/src/main/scala/update/utils/Rewriter.scala b/src/main/scala/update/utils/Rewriter.scala new file mode 100644 index 0000000..49dd033 --- /dev/null +++ b/src/main/scala/update/utils/Rewriter.scala @@ -0,0 +1,42 @@ +package update.utils + +object Rewriter: + final case class Patch(start: Int, end: Int, replacement: String): + def length: Int = end - start + + /** This function rewrites a given source string based on a list of patches. + * Each patch contains a start and end index, and a replacement string. The + * function applies these patches in order of their start index. If any + * patches overlap (i.e., a patch starts before the previous one ends), an + * exception is thrown. + */ + def rewrite(source: String, patches: List[Patch]): String = + val sortedPatches = patches.sortBy(_.start) + + val sb = new StringBuilder + var lastEnd = 0 + + // Apply each patch in order + sortedPatches.foreach { patch => + // If a patch starts before the last one ends, throw an exception + if patch.start < lastEnd then + throw new IllegalArgumentException( + s"Overlapping patches: $patch and ${sortedPatches.find(_.start < lastEnd).get}" + ) + + // Append the part of the source string before the patch + try sb.append(source.substring(lastEnd, patch.start)) + catch + case e: StringIndexOutOfBoundsException => + throw new IllegalArgumentException( + s"Patch $patch starts at index ${patch.start} but source string has length ${source.length}" + ) + // Append the replacement string + sb.append(patch.replacement) + // Update the end index of the last applied patch + lastEnd = patch.end + } + + // Append the part of the source string after the last patch + sb.append(source.substring(lastEnd)) + sb.toString diff --git a/src/main/scala/update/utils/UntypedInteractiveCompiler.scala b/src/main/scala/update/utils/UntypedInteractiveCompiler.scala new file mode 100644 index 0000000..b51c194 --- /dev/null +++ b/src/main/scala/update/utils/UntypedInteractiveCompiler.scala @@ -0,0 +1,124 @@ +package update.utils + +import dotty.tools.* +import dotty.tools.dotc.ast.{Trees, tpd} +import dotty.tools.dotc.core.* +import dotty.tools.dotc.core.Phases.Phase +import dotty.tools.dotc.interactive.* +import dotty.tools.dotc.parsing.Parser +import dotty.tools.dotc.{CompilationUnit, Compiler, Driver, ast, core, reporting, typer, util} + +import scala.collection.* +import scala.language.unsafeNulls + +class UntypedInteractiveCompiler extends Compiler: + override def phases: List[List[Phase]] = List( + List(new Parser) + ) + +import dotty.tools.dotc.ast.Trees.* +import dotty.tools.dotc.core.Contexts.* +import dotty.tools.dotc.reporting.* +import dotty.tools.dotc.util.* +import dotty.tools.io.AbstractFile + +import java.net.URI +import scala.language.unsafeNulls + +/** A driver which simply parses and produces untyped Trees */ +class ParsingDriver(val settings: List[String]) extends Driver: + import tpd.* + + override def sourcesRequired: Boolean = false + + private val myInitCtx: Context = + val rootCtx = initCtx.fresh.addMode(Mode.ReadPositions).addMode(Mode.Interactive) + rootCtx.setSetting(rootCtx.settings.YretainTrees, true) + rootCtx.setSetting(rootCtx.settings.YcookComments, true) + rootCtx.setSetting(rootCtx.settings.YreadComments, true) + val ctx = setup(settings.toArray, rootCtx) match + case Some((_, ctx)) => ctx + case None => rootCtx + ctx.initialize()(using ctx) + ctx + + private var myCtx: Context = myInitCtx + def currentCtx: Context = myCtx + + private val compiler: Compiler = new UntypedInteractiveCompiler + + private val myOpenedFiles = new mutable.LinkedHashMap[URI, SourceFile]: + override def default(key: URI) = NoSource + + private val myOpenedTrees = new mutable.LinkedHashMap[URI, List[SourceTree]]: + override def default(key: URI) = Nil + + private val myCompilationUnits = new mutable.LinkedHashMap[URI, CompilationUnit] + + initialize() + + def run(uri: URI, sourceCode: String): List[Diagnostic] = run(uri, SourceFile.virtual(uri, sourceCode)) + + def run(uri: URI, source: SourceFile): List[Diagnostic] = + import typer.ImportInfo.* + + val previousCtx = myCtx + try + val reporter = + new StoreReporter(null) with UniqueMessagePositions with HideNonSensicalMessages + + val run = compiler.newRun(using myInitCtx.fresh.setReporter(reporter)) + myCtx = run.runContext.withRootImports + + given Context = myCtx + + myOpenedFiles(uri) = source + + run.compileSources(List(source)) + run.printSummary() + val ctxRun = ctx.run.nn + val unit = if ctxRun.units.nonEmpty then ctxRun.units.head else ctxRun.suspendedUnits.head + val t = unit.tpdTree + myOpenedTrees(uri) = topLevelTrees(t, source) + myCompilationUnits(uri) = unit + myCtx = myCtx.fresh.setPhase(myInitCtx.base.typerPhase) + + reporter.removeBufferedMessages + catch + case _: FatalError => + myCtx = previousCtx + close(uri) + Nil + + def close(uri: URI): Unit = + myOpenedFiles.remove(uri) + myOpenedTrees.remove(uri) + myCompilationUnits.remove(uri) + + private def topLevelTrees(topTree: Tree, source: SourceFile): List[SourceTree] = + val trees = new mutable.ListBuffer[SourceTree] + + def addTrees(tree: Tree): Unit = tree match + case PackageDef(_, stats) => + stats.foreach(addTrees) + case imp: Import => + trees += SourceTree(imp, source) + case tree: TypeDef => + trees += SourceTree(tree, source) + case _ => + addTrees(topTree) + + trees.toList + + /** Initialize this driver and compiler. + * + * This is necessary because an `InteractiveDriver` can be put to work + * without having compiled anything (for instance, resolving a symbol coming + * from a different compiler in this compiler). In those cases, an + * un-initialized compiler may crash (for instance if late-compilation is + * needed). + */ + private def initialize(): Unit = + val run = compiler.newRun(using myInitCtx.fresh) + myCtx = run.runContext + run.compileUnits(Nil, myCtx) diff --git a/src/main/scala/update/versions/Versions.scala b/src/main/scala/update/versions/Versions.scala deleted file mode 100644 index 4df5046..0000000 --- a/src/main/scala/update/versions/Versions.scala +++ /dev/null @@ -1,37 +0,0 @@ -package update.versions - -import update.{Artifact, Dependency, Group, Version} -import zio._ - -trait Versions { - def getVersions(group: Group, artifact: Artifact, sbtVersion: Option[Version]): Task[List[Version]] - - def getVersions(dependency: Dependency, sbtVersion: Option[Version]): Task[List[Version]] = - getVersions(dependency.group, dependency.artifact, sbtVersion) -} - -object Versions { - - def getVersions( - group: Group, - artifact: Artifact, - sbtVersion: Option[Version] - ): ZIO[Versions, Throwable, List[Version]] = - ZIO.serviceWithZIO[Versions](_.getVersions(group, artifact, sbtVersion)) - - def getVersions(dependency: Dependency, sbtVersion: Option[Version]): ZIO[Versions, Throwable, List[Version]] = - ZIO.serviceWithZIO[Versions](_.getVersions(dependency, sbtVersion)) - - val live: ULayer[Versions] = - ZLayer.fromFunction(VersionsLive.apply _) - - def test(versions: Map[(Group, Artifact), List[Version]]): ULayer[Versions] = - ZLayer.succeed { - VersionsFake(versions) - } -} - -final case class VersionsFake(versions: Map[(Group, Artifact), List[Version]]) extends Versions { - override def getVersions(group: Group, artifact: Artifact, sbtVersion: Option[Version]): Task[List[Version]] = - ZIO.succeed(versions.getOrElse((group, artifact), Nil)) -} diff --git a/src/main/scala/update/versions/VersionsLive.scala b/src/main/scala/update/versions/VersionsLive.scala deleted file mode 100644 index 7058b7d..0000000 --- a/src/main/scala/update/versions/VersionsLive.scala +++ /dev/null @@ -1,63 +0,0 @@ -package update.versions - -import coursier.cache.FileCache -import coursier.{Module, ModuleName, Organization, Resolve} -import update.versions.ZioSyncInstance._ -import update.{Artifact, Dependency, Group, Version} -import zio._ - -final case class VersionsLive() extends Versions { - private val cache = FileCache[Task]() - private val resolve = Resolve[Task](cache) - - // Currently hardcoded to Scala 2.13 - override def getVersions(group: Group, artifact: Artifact, sbtVersion: Option[Version]): Task[List[Version]] = - // Gets library versions - getVersions(group, artifact, "2.13", None) - .filterOrElse(_.nonEmpty) { - // Gets plugin versions - getVersions(group, artifact, "2.12", sbtVersion.map(_.value)) - } - - private def getVersions( - group: Group, - artifact: Artifact, - scalaVersion: String, - sbtVersion: Option[String] - ): Task[List[Version]] = { - val isSbt = Dependency.isSbt(group, artifact) - - resolve.finalRepositories.flatMap { repositories => - ZIO - .foreachPar(repositories) { repository => - val attributes = - if (isSbt) - Map.empty[String, String] - else - Map("scalaVersion" -> scalaVersion) ++ sbtVersion.map("sbtVersion" -> _) - - repository - .versions( - Module( - Organization(group.value), - ModuleName(artifact.value), - attributes - ), - cache.fetch, - versionsCheckHasModule = !isSbt - ) - .run - .map(_.left.map(new Error(_))) - .absolve - .map { case (versions, _) => - versions.available.map(Version(_)) - } - .catchSome { - case e if e.getMessage.contains("not found") => - ZIO.succeed(List.empty) - } - } - .map(_.flatten.toList) - } - } -} diff --git a/src/main/scala/update/versions/ZioSyncInstance.scala b/src/main/scala/update/versions/ZioSyncInstance.scala deleted file mode 100644 index 9ba1968..0000000 --- a/src/main/scala/update/versions/ZioSyncInstance.scala +++ /dev/null @@ -1,31 +0,0 @@ -package update.versions - -import coursier.util.Sync -import zio.{Task, ZIO} - -import java.util.concurrent.ExecutorService -import scala.concurrent.ExecutionContext - -object ZioSyncInstance { - - implicit lazy val taskSync: Sync[Task] = new Sync[Task] { - override def delay[A](a: => A): Task[A] = ZIO.attempt(a) - - override def handle[A](a: Task[A])(f: PartialFunction[Throwable, A]): Task[A] = - a.catchSome { case f(e) => ZIO.succeed(e) } - - override def fromAttempt[A](a: Either[Throwable, A]): Task[A] = - ZIO.fromEither(a) - - override def gather[A](elems: Seq[Task[A]]): Task[Seq[A]] = - ZIO.collectAll(elems) - - override def point[A](a: A): Task[A] = ZIO.succeed(a) - - override def bind[A, B](elem: Task[A])(f: A => Task[B]): Task[B] = - elem.flatMap(f) - - override def schedule[A](pool: ExecutorService)(f: => A): Task[A] = - ZIO.attempt(f).onExecutionContext(ExecutionContext.fromExecutor(pool)) - } -} diff --git a/src/test/scala/update/DependencyLoaderSpec.scala b/src/test/scala/update/DependencyLoaderSpec.scala new file mode 100644 index 0000000..682c2c8 --- /dev/null +++ b/src/test/scala/update/DependencyLoaderSpec.scala @@ -0,0 +1,55 @@ +package update + +import update.model.Dependency +import update.services.Files +import update.services.dependencies.DependencyLoader +import update.test.utils.TestFileHelper +import zio.* +import zio.nio.file +import zio.test.* + +object DependencyLoaderSpec extends ZIOSpecDefault: + + val buildSbtString = """ +val zioVersion = "1.0.12" + +libraryDependencies ++= Seq( + "dev.zio" %% "zio" % zioVersion, + "dev.zio" %% "zio-json" % "0.2.0", + "io.getquill" %% "quill-jdbc-zio" % Dependencies.quill, + "dev.cheleb" %% "zio-pravega" % "0.1.0-RC12", + "org.postgresql" % "postgresql" % "42.2.5" +) +""" + + val dependenciesString = """ +object Dependencies { + val quill = "3.10.0" +} +""" + + def spec = + suiteAll("DependencyUpdate") { + test("should update the version of a dependency") { + for + dir <- TestFileHelper.createTempFiles( + "build.sbt" -> buildSbtString, + "project/Dependencies.scala" -> dependenciesString + ) + dependencies <- DependencyLoader.getDependencies(dir.toString) + yield assertTrue( + dependencies.map(_.value).toSet == + Set( + Dependency("dev.zio", "zio", "1.0.12", false), + Dependency("dev.zio", "zio-json", "0.2.0", false), + Dependency("io.getquill", "quill-jdbc-zio", "3.10.0", false), + Dependency("dev.cheleb", "zio-pravega", "0.1.0-RC12", false), + Dependency("org.postgresql", "postgresql", "42.2.5", true) + ) + ) + } + }.provide( + Scope.default, + DependencyLoader.live, + Files.live + ) diff --git a/src/test/scala/update/DependencyParserSpec.scala b/src/test/scala/update/DependencyParserSpec.scala deleted file mode 100644 index 4ebaeca..0000000 --- a/src/test/scala/update/DependencyParserSpec.scala +++ /dev/null @@ -1,60 +0,0 @@ -package update - -import zio.Chunk -import zio.nio.file.Path -import zio.test._ - -object DependencyParserSpec extends ZIOSpecDefault { - - val spec = - suite("DependencyParserSpec")( - test("parse dependencies from trees") { - val sourceFiles = - Chunk( - SourceFile.Sbt1DialectSourceFile( - Path("fake"), - """ -val V = { - val zio = "1.0.14" - val `zio-json` = "0.3.0" - val other = "3.5.0" -} - -libraryDependencies ++= Seq( - "dev.zio" %% "zio" % V.zio, - "dev.zio" %%% "zio-test" % V.zioTest % Test, - "dev.zio" % "zio-json" % V.`zio-json` -) -""" - ), - SourceFile.Sbt1DialectSourceFile( - Path("fake"), - """ -val V = { - val zioTest = "1.0.14" -} - -libraryDependencies ++= Seq( - "other.dev" % "other" % V.other, - "other.dev" %%% "other.test" % "3.5.8" % Test -) -""" - ) - ) - - val deps = DependencyParser.getDependencies(sourceFiles) - - val expected = - Chunk( - Dependency(Group("dev.zio"), Artifact("zio"), Version("1.0.14")), - Dependency(Group("dev.zio"), Artifact("zio-test"), Version("1.0.14")), - Dependency(Group("dev.zio"), Artifact("zio-json"), Version("0.3.0")), - Dependency(Group("other.dev"), Artifact("other"), Version("3.5.0")), - Dependency(Group("other.dev"), Artifact("other.test"), Version("3.5.8")) - ) - - assertTrue(deps.map(_.dependency) == expected) - } - ) - -} diff --git a/src/test/scala/update/DependencyUpdaterSpec.scala b/src/test/scala/update/DependencyUpdaterSpec.scala deleted file mode 100644 index 61a2017..0000000 --- a/src/test/scala/update/DependencyUpdaterSpec.scala +++ /dev/null @@ -1,83 +0,0 @@ -package update - -import update.versions.Versions -import zio.ZIO -import zio.test._ - -object DependencyUpdaterSpec extends ZIOSpecDefault { - def spec = - suite("DependencyUpdaterSpec")( - test("updateDependencies") { - - val buildSbtString = - """ -val zioVersion = "1.0.12" - -libraryDependencies ++= Seq( - "dev.zio" %% "zio" % zioVersion, - "dev.zio" %% "zio-json" % "0.2.0", - "io.getquill" %% "quill-jdbc-zio" % Dependencies.quill, - "dev.cheleb" %% "zio-pravega" % "0.1.0-RC12" -) -""" - - val expectedSbtString = - """ -val zioVersion = "2.0.0" - -libraryDependencies ++= Seq( - "dev.zio" %% "zio" % zioVersion, - "dev.zio" %% "zio-json" % "0.3.1", - "io.getquill" %% "quill-jdbc-zio" % Dependencies.quill, - "dev.cheleb" %% "zio-pravega" % "0.1.0-RC12" -) -""" - - val dependenciesString = - """ -object Dependencies { - val quill = "0.9.0" -} -""" - - val expectedDependenciesString = - """ -object Dependencies { - val quill = "0.9.5" -} -""" - - ZIO.scoped { - for { - tempDir <- zio.nio.file.Files.createTempDirectoryScoped(Some("dependency-updater-spec"), Iterable.empty) - buildSbtPath = tempDir / "build.sbt" - dependenciesPath = tempDir / "project" / "Dependencies.scala" - _ <- zio.nio.file.Files.createFile(buildSbtPath) - _ <- zio.nio.file.Files.createDirectory(tempDir / "project") - _ <- zio.nio.file.Files.createFile(dependenciesPath) - _ <- ZIO.writeFile(buildSbtPath.toString, buildSbtString) - _ <- ZIO.writeFile(dependenciesPath.toString, dependenciesString) - _ <- TestSystem.putProperty("user.dir", tempDir.toString) - _ <- ZIO.serviceWithZIO[DependencyUpdater](_.updateDependencies) - newBuildSbt <- ZIO.readFile(buildSbtPath.toString) - newDependencies <- ZIO.readFile(dependenciesPath.toString) - } yield assertTrue( - newBuildSbt == expectedSbtString, - newDependencies == expectedDependenciesString - ) - - } - } - ).provide( - DependencyUpdater.live, - Files.live, - Versions.test( - Map( - (Group("dev.zio"), Artifact("zio")) -> List(Version("2.0.0")), - (Group("io.getquill"), Artifact("quill-jdbc-zio")) -> List(Version("0.9.5")), - (Group("dev.zio"), Artifact("zio-json")) -> List(Version("0.3.1")), - (Group("dev.cheleb"), Artifact("zio-pravega")) -> List(Version("0.1.0-RC9")) - ) - ) - ) -} diff --git a/src/test/scala/update/MillDependencyParserSpec.scala b/src/test/scala/update/MillDependencyParserSpec.scala deleted file mode 100644 index 2e598df..0000000 --- a/src/test/scala/update/MillDependencyParserSpec.scala +++ /dev/null @@ -1,97 +0,0 @@ -package update - -import zio.Chunk -import zio.nio.file.Path -import zio.test._ - -object MillDependencyParserSpec extends ZIOSpecDefault { - - val spec = - suite("MillDependencyParserSpec")( - test("parse dependencies from trees") { - val sourceFiles = - Chunk( - SourceFile.Sbt1DialectSourceFile( - Path("fake"), - """ -import mill._, scalalib._ - -object V { - val zio = "1.0.14" - val `zio-json` = "0.3.0" - val zioTest = "1.0.14" -} - -object foo extends ScalaModule { - def scalaVersion = "2.13.8" - import V._ - def ivyDeps = Agg( - ivy"dev.zio::zio:$zio", - ivy"dev.zio::zio-test::${V.zioTest}", - ivy"dev.zio::zio-json:${V.`zio-json`}", - ) -} - -""" - ) - ) - - val deps = DependencyParser.getDependencies(sourceFiles) - - val expected = - Chunk( - Dependency(Group("dev.zio"), Artifact("zio"), Version("1.0.14")), - Dependency(Group("dev.zio"), Artifact("zio-test"), Version("1.0.14")), - Dependency(Group("dev.zio"), Artifact("zio-json"), Version("0.3.0")) - ) - - assertTrue(deps.map(_.dependency) == expected) - }, - test("parse dependencies from multiple files") { - val sourceFiles = - Chunk( - SourceFile.Sbt1DialectSourceFile( - Path("fake"), - """ -import mill._, scalalib._ -import $file.Versions, Versions.V, Versions.V._ - -object foo extends ScalaModule { - def scalaVersion = "2.13.8" - def ivyDeps = Agg( - ivy"dev.zio::zio:$zio", - ivy"dev.zio::zio-test::${V.zioTest}", - ivy"dev.zio::zio-json:${V.`zio-json`}", - ) -} - -""" - ), - SourceFile.Sbt1DialectSourceFile( - Path("Versions.sc"), - """ -import mill._, scalalib._ - -object V { - val zio = "1.0.14" - val `zio-json` = "0.3.0" - val zioTest = "1.0.14" -} -""" - ) - ) - - val deps = DependencyParser.getDependencies(sourceFiles) - - val expected = - Chunk( - Dependency(Group("dev.zio"), Artifact("zio"), Version("1.0.14")), - Dependency(Group("dev.zio"), Artifact("zio-test"), Version("1.0.14")), - Dependency(Group("dev.zio"), Artifact("zio-json"), Version("0.3.0")) - ) - - assertTrue(deps.map(_.dependency) == expected) - } - ) - -} diff --git a/src/test/scala/update/MillDependencyUpdaterSpec.scala b/src/test/scala/update/MillDependencyUpdaterSpec.scala deleted file mode 100644 index 511444c..0000000 --- a/src/test/scala/update/MillDependencyUpdaterSpec.scala +++ /dev/null @@ -1,93 +0,0 @@ -package update - -import update.versions.Versions -import zio.ZIO -import zio.test._ - -object MillDependencyUpdaterSpec extends ZIOSpecDefault { - def spec = - suite("MillDependencyUpdaterSpec")( - test("updateDependencies") { - - val buildMillString = - """ -import $file.Dependencies, Dependencies.Dependencies - -object foo extends ScalaModule { - def scalaVersion = "2.13.8" - val zioVersion = "1.0.12" - - def ivyDeps = Agg( - ivy"dev.zio::zio:$zioVersion", - ivy"dev.zio::zio-json:0.2.0", - ivy"io.getquill::quill-jdbc-zio:${Dependencies.quill}", - ivy"dev.cheleb:zio-pravega:0.1.0-RC12" - ) -} -""" - - val expectedMillString = - """ -import $file.Dependencies, Dependencies.Dependencies - -object foo extends ScalaModule { - def scalaVersion = "2.13.8" - val zioVersion = "2.0.0" - - def ivyDeps = Agg( - ivy"dev.zio::zio:$zioVersion", - ivy"dev.zio::zio-json:0.2.0", - ivy"io.getquill::quill-jdbc-zio:${Dependencies.quill}", - ivy"dev.cheleb:zio-pravega:0.1.0-RC12" - ) -} -""" - - val dependenciesString = - """ -object Dependencies { - val quill = "0.9.0" -} -""" - - val expectedDependenciesString = - """ -object Dependencies { - val quill = "0.9.5" -} -""" - - ZIO.scoped { - for { - tempDir <- zio.nio.file.Files.createTempDirectoryScoped(Some("dependency-updater-spec"), Iterable.empty) - buildMillPath = tempDir / "build.sc" - dependenciesPath = tempDir / "Dependencies.sc" - _ <- zio.nio.file.Files.createFile(buildMillPath) - _ <- zio.nio.file.Files.createDirectory(tempDir / "project") - _ <- zio.nio.file.Files.createFile(dependenciesPath) - _ <- ZIO.writeFile(buildMillPath.toString, buildMillString) - _ <- ZIO.writeFile(dependenciesPath.toString, dependenciesString) - _ <- TestSystem.putProperty("user.dir", tempDir.toString) - _ <- ZIO.serviceWithZIO[DependencyUpdater](_.updateDependencies) - newBuildSbt <- ZIO.readFile(buildMillPath.toString) - newDependencies <- ZIO.readFile(dependenciesPath.toString) - } yield assertTrue( - newBuildSbt == expectedMillString, - newDependencies == expectedDependenciesString - ) - - } - } - ).provide( - DependencyUpdater.live, - Files.live, - Versions.test( - Map( - (Group("dev.zio"), Artifact("zio")) -> List(Version("2.0.0")), - (Group("io.getquill"), Artifact("quill-jdbc-zio")) -> List(Version("0.9.5")), - (Group("dev.zio"), Artifact("zio-json")) -> List(Version("0.3.1")), - (Group("dev.cheleb"), Artifact("zio-pravega")) -> List(Version("0.1.0-RC9")) - ) - ) - ) -} diff --git a/src/test/scala/update/ReplacementSpec.scala b/src/test/scala/update/ReplacementSpec.scala deleted file mode 100644 index a4e63d4..0000000 --- a/src/test/scala/update/ReplacementSpec.scala +++ /dev/null @@ -1,99 +0,0 @@ -package update - -import zio.Chunk -import zio.nio.file.Path -import zio.test._ - -object ReplacementSpec extends ZIOSpecDefault { - def spec = - suite("ReplacementSpec")( - test("replace updated versions parsed from build definition with version definitions") { - val parsed = - SourceFile.Sbt1DialectSourceFile( - Path("fake"), - """ -object V { - val zio = "1.0.14" - val `zio-test` = "0.1.0" - val `zio-spike` = "0.1.0" - val `zio-json` = "0.1.0" -} -""" - ) - - val assignments = DependencyParser.parseVersionDefs(parsed) - - val result = Replacement.replace( - parsed.content, - assignments.values.toList.map(v => - Replacement(v.location.start, v.location.end, "\"" + v.version.value + "-RC10" + "\"") - ) - ) - - val expected = - """ -object V { - val zio = "1.0.14-RC10" - val `zio-test` = "0.1.0-RC10" - val `zio-spike` = "0.1.0-RC10" - val `zio-json` = "0.1.0-RC10" -} -""" - - assertTrue(result == expected) - }, - test("replace updated versions parsed from build definition with inline versions") { - val parsed = - SourceFile.Sbt1DialectSourceFile( - Path("build.sbt"), - """ -libraryDependencies ++= Seq( - "dev.zio" %% "zio" % "1.0.14", - "dev.zio" %% "zio-test" % "0.1.0", - "dev.zio" %% "zio-spike" % "0.1.0", - "dev.zio" %% "zio-json" % "0.1.0" -) -""" - ) - - val assignments = DependencyParser.getDependencies(Chunk.succeed(parsed)).toList - - val result = Replacement.replace( - parsed.content, - assignments.toList.map(v => - Replacement(v.location.start, v.location.end, "\"" + v.dependency.version.value + "-RC10" + "\"") - ) - ) - - val expected = - """ -libraryDependencies ++= Seq( - "dev.zio" %% "zio" % "1.0.14-RC10", - "dev.zio" %% "zio-test" % "0.1.0-RC10", - "dev.zio" %% "zio-spike" % "0.1.0-RC10", - "dev.zio" %% "zio-json" % "0.1.0-RC10" -) -""" - - assertTrue(result == expected) - }, - test("replace updated sbt version parsed from build.properties file") { - val parsed = - SourceFile.BuildPropertiesSourceFile( - Path("project/build.properties"), - """sbt.version = 1.2.3""" - ) - - val assignments = DependencyParser.getDependencies(Chunk.succeed(parsed)).toList - - val result = Replacement.replace( - parsed.content, - assignments.map(v => Replacement(v.location.start, v.location.end, s"${v.dependency.version.value}-RC10")) - ) - - val expected = """sbt.version = 1.2.3-RC10""" - - assertTrue(result == expected) - } - ) -} diff --git a/src/test/scala/update/RewriterSpec.scala b/src/test/scala/update/RewriterSpec.scala new file mode 100644 index 0000000..10fdc4d --- /dev/null +++ b/src/test/scala/update/RewriterSpec.scala @@ -0,0 +1,64 @@ +package update + +import update.utils.Rewriter +import update.utils.Rewriter.Patch +import zio.test.* + +object RewriterSpec extends ZIOSpecDefault: + def spec = + suiteAll("Rewriter") { + test("rewrite with a single patch") { + val source = "Hello World" + val patches = List(Patch(6, 11, "Scala")) + val result = Rewriter.rewrite(source, patches) + assertTrue(result == "Hello Scala") + } + + test("rewrite with multiple non-overlapping patches") { + val source = "Hello World" + val patches = List(Patch(0, 5, "Hi"), Patch(6, 11, "Scala")) + val result = Rewriter.rewrite(source, patches) + assertTrue(result == "Hi Scala") + } + + // this should throw an error + test("rewrite with overlapping patches") { + val source = "Hello World" + val patches = List(Patch(0, 11, "Bonjour"), Patch(6, 11, "Scala")) + + try + val result = Rewriter.rewrite(source, patches) + assertTrue(false) + catch + case e: Exception => + assertTrue(e.getMessage.contains("Overlapping patches")) + } + + test("rewrite with patches at the beginning and end of source") { + val source = "Hello World" + val patches = List(Patch(0, 1, "J"), Patch(10, 11, "d!")) + val result = Rewriter.rewrite(source, patches) + assertTrue(result == "Jello World!") + } + + test("rewrite with empty replacement") { + val source = "Hello World" + val patches = List(Patch(5, 6, "")) + val result = Rewriter.rewrite(source, patches) + assertTrue(result == "HelloWorld") + } + + test("rewrite with no patches") { + val source = "Hello World" + val patches = List() + val result = Rewriter.rewrite(source, patches) + assertTrue(result == "Hello World") + } + + test("rewrite with a patch that replaces the entire source") { + val source = "Hello World" + val patches = List(Patch(0, source.length, "Goodbye")) + val result = Rewriter.rewrite(source, patches) + assertTrue(result == "Goodbye") + } + } diff --git a/src/test/scala/update/SbtDependencyParserSpec.scala b/src/test/scala/update/SbtDependencyParserSpec.scala deleted file mode 100644 index 97863e4..0000000 --- a/src/test/scala/update/SbtDependencyParserSpec.scala +++ /dev/null @@ -1,29 +0,0 @@ -package update - -import zio.Chunk -import zio.nio.file.Path -import zio.test._ - -object SbtDependencyParserSpec extends ZIOSpecDefault { - val spec = - suite("SbtDependencyParserSpec")( - test("parse dependencies from file content") { - val sourceFiles = - Chunk( - SourceFile.BuildPropertiesSourceFile( - Path("project/build.properties"), - """sbt.version = 1.2.3""" - ) - ) - - val deps = DependencyParser.getDependencies(sourceFiles) - - val expected = - Chunk( - Dependency(Dependency.sbtGroup, Dependency.sbtArtifact, Version("1.2.3")) - ) - - assertTrue(deps.map(_.dependency) == expected) - } - ) -} diff --git a/src/test/scala/update/UpdateOptionsSpec.scala b/src/test/scala/update/UpdateOptionsSpec.scala deleted file mode 100644 index 750b1bd..0000000 --- a/src/test/scala/update/UpdateOptionsSpec.scala +++ /dev/null @@ -1,83 +0,0 @@ -package update - -import zio.test._ - -object UpdateOptionsSpec extends ZIOSpecDefault { - def spec = - suite("UpdateOptionsSpec")( - test("correctly categorizes updates") { - val result = UpdateOptions.getOptions( - Version("1.0.0"), - List( - Version("1.0.0"), - Version("1.0.1"), - Version("1.0.2"), - Version("1.1.2"), - Version("1.2.0"), - Version("1.2.1"), - Version("2.2.2"), - Version("2.2.3") - ) - ) - - val expected = - UpdateOptions( - major = Some(Version("2.2.3")), - minor = Some(Version("1.2.1")), - patch = Some(Version("1.0.2")), - preRelease = None - ) - - assertTrue(result == expected) - }, - test("correctly sorts dependencies") { - val result = UpdateOptions.getOptions( - Version("1.9.0"), - List( - Version("1.10.0"), - Version("1.5.1"), - Version("1.7.0"), - Version("1.10.1"), - Version("1.5.0"), - Version("1.6.0"), - Version("1.7.1"), - Version("1.8.0"), - Version("1.9.0") - ) - ) - - val expected = - UpdateOptions( - major = None, - minor = Some(Version("1.10.1")), - patch = None, - preRelease = None - ) - - assertTrue(result == expected) - }, - test("sorts pre-releases") { - - val result = UpdateOptions.getOptions( - Version("1.0.0"), - List( - Version("1.0.1"), - Version("2.0.0-RC2"), - Version("2.0.0-M3"), - Version("2.0.0-RC1") - ) - ) - - val expected = - UpdateOptions( - major = None, - minor = None, - patch = Some(Version("1.0.1")), - preRelease = Some(Version("2.0.0-RC2")) - ) - - assertTrue(result == expected) - - } - ) -} diff --git a/src/test/scala/update/UpgradeOptionsSpec.scala b/src/test/scala/update/UpgradeOptionsSpec.scala deleted file mode 100644 index 7507069..0000000 --- a/src/test/scala/update/UpgradeOptionsSpec.scala +++ /dev/null @@ -1,81 +0,0 @@ -package update - -import zio.test._ - -object UpgradeOptionsSpec extends ZIOSpecDefault { - - def spec = - suite("UpgradeOptionsSpec")( - test("simple version") { - val result = UpdateOptions - .getOptions( - Version("1.0.0"), - List( - Version("1.0.0"), - Version("1.0.1"), - Version("1.0.2"), // newest patch version - Version("1.1.0"), - Version("1.1.1"), // newest minor version - Version("2.1.1"), - Version("2.1.2"), // newest major version - Version("3.0.0-M8"), - Version("3.0.0-RC1"), - Version("3.0.0-RC3") // newest prerelease version - ) - ) - - val expected = - UpdateOptions( - Some(Version("2.1.2")), - Some(Version("1.1.1")), - Some(Version("1.0.2")), - Some(Version("3.0.0-RC3")) - ) - - assertTrue(result == expected) - }, - test("only minor") { - val result = UpdateOptions - .getOptions( - Version("1.0.0"), - List( - Version("1.1.0"), - Version("1.1.1") // newest minor version - ) - ) - - val expected = - UpdateOptions( - None, - Some(Version("1.1.1")), - None, - None - ) - - assertTrue(result == expected) - }, - test("rc version") { - val result = UpdateOptions - .getOptions( - Version("1.0.0-RC1"), - List( - Version("1.0.0"), - Version("1.0.0-alpha1"), - Version("1.0.0-RC2") - ) - ) - - val expected = - UpdateOptions( - Some(Version("1.0.0")), - None, - None, - // TODO: Fix subtle bug here -// Some(Version("1.0.0-RC2")) - None - ) - - assertTrue(result == expected) - } - ) -} diff --git a/src/test/scala/update/VersionSpec.scala b/src/test/scala/update/VersionSpec.scala deleted file mode 100644 index 9b9ae69..0000000 --- a/src/test/scala/update/VersionSpec.scala +++ /dev/null @@ -1,41 +0,0 @@ -package update - -import zio.test._ - -object VersionSpec extends ZIOSpecDefault { - def spec = suite("Version")( - test("ordering") { - val versions = List( - Version("1.10.0"), - Version("1.10.1"), - Version("1.5.0"), - Version("1.5.1"), - Version("1.6.0"), - Version("1.7.0"), - Version("1.7.1"), - Version("1.8.0"), - Version("2.0.0"), - Version("1.9.0") - ) - - val expected = - List( - Version("1.5.0"), - Version("1.5.1"), - Version("1.6.0"), - Version("1.7.0"), - Version("1.7.1"), - Version("1.8.0"), - Version("1.9.0"), - Version("1.10.0"), - Version("1.10.1"), - Version("2.0.0") - ) - - val result: List[Version] = - versions.sorted - - assertTrue(result == expected) - } - ) -} diff --git a/src/test/scala/update/model/VersionSpec.scala b/src/test/scala/update/model/VersionSpec.scala new file mode 100644 index 0000000..4451079 --- /dev/null +++ b/src/test/scala/update/model/VersionSpec.scala @@ -0,0 +1,73 @@ +package update.model + +import zio.test.* + +object VersionSpec extends ZIOSpecDefault: + private val parsingExpectations = + List( + "1.0.0" -> Version.SemVer(1, 0, 0, None), + "1.0.0-RC4" -> Version.SemVer(1, 0, 0, Some(PreRelease.RC(4))), + "2.0-RC4" -> Version.SemVer(2, 0, 0, Some(PreRelease.RC(4))), + "1.5.5-M1" -> Version.SemVer(1, 5, 5, Some(PreRelease.M(1))), + "4.5.5.5" -> Version.Other("4.5.5.5"), + "i-hate-semver" -> Version.Other("i-hate-semver"), + "1.1.1-alpha" -> Version.SemVer(1, 1, 1, Some(PreRelease.Alpha(None))), + "1.1.1-alpha.5" -> Version.SemVer(1, 1, 1, Some(PreRelease.Alpha(Some(5)))), + "1.1.1-beta" -> Version.SemVer(1, 1, 1, Some(PreRelease.Beta(None))), + "1.1.1-beta.5" -> Version.SemVer(1, 1, 1, Some(PreRelease.Beta(Some(5)))) + ) + + private val orderingExpectations = + List( + List("1.1.1", "2.0.0", "0.0.5") -> List("0.0.5", "1.1.1", "2.0.0"), + List( + "0.5.0", + "0.1.0-RC12", + "0.1.0-RC13", + "1.0.0", + "1.0.0-RC2", + "1.0.0-RC1", + "1.0.0-M1", + "1.0.0-alpha", + "1.0.0-alpha.1", + "2.0.0", + "2.1.0", + "2.0.1" + ) -> List( + "0.1.0-RC12", + "0.1.0-RC13", + "0.5.0", + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-M1", + "1.0.0-RC1", + "1.0.0-RC2", + "1.0.0", + "2.0.0", + "2.0.1", + "2.1.0" + ) + ).map { (input, expected) => + input.map(Version(_)) -> expected.map(Version(_)) + } + + def spec = + suiteAll("Version") { + + suite("Parsing")( + parsingExpectations.map { (input, expected) => + test(s"parse $input") { + assertTrue(Version(input) == expected) + } + }* + ) + + suite("Ordering")( + orderingExpectations.map { (input, expected) => + test(s"order $input") { + assertTrue(input.sorted == expected) + } + }* + ) + + } diff --git a/src/test/scala/update/services/ScalaUpdateSpec.scala b/src/test/scala/update/services/ScalaUpdateSpec.scala new file mode 100644 index 0000000..22a8965 --- /dev/null +++ b/src/test/scala/update/services/ScalaUpdateSpec.scala @@ -0,0 +1,159 @@ +package update.services + +import update.model.* +import update.services.dependencies.DependencyLoader +import update.test.utils.TestFileHelper +import zio.* +import zio.test.* + +object ScalaUpdateSpec extends ZIOSpecDefault: + + ////////////////////////// + // Original Build Files // + ////////////////////////// + + val buildSbtString = """ +val zioVersion = "1.0.12" + +libraryDependencies ++= Seq( + "dev.zio" %% "zio" % zioVersion, + "dev.zio" %% "zio-json" % "0.2.0", + "io.getquill" %% "quill-jdbc-zio" % Dependencies.quill, + "dev.cheleb" %% "zio-pravega" % "0.1.0-RC12", + "org.postgresql" % "postgresql" % Dependencies.postgresVersion +) +""" + + val expectedBuildSbtString = + """ +val zioVersion = "2.0.0" + +libraryDependencies ++= Seq( + "dev.zio" %% "zio" % zioVersion, + "dev.zio" %% "zio-json" % "2.2.2", + "io.getquill" %% "quill-jdbc-zio" % Dependencies.quill, + "dev.cheleb" %% "zio-pravega" % "0.1.0-RC12", + "org.postgresql" % "postgresql" % Dependencies.postgresVersion +) +""" + + val buildMillString = """ +import $file.Dependencies, Dependencies.Dependencies + +object foo extends ScalaModule { + def scalaVersion = "2.13.8" + val zioJsonVersion = "1.2.2" + + def ivyDeps = Agg( + ivy"dev.zio::zio-json:$zioJsonVersion", + ivy"io.getquill::quill-jdbc-zio:${Dependencies.quill}", + ivy"dev.cheleb:zio-pravega:0.1.0-RC12", + ivy"org.postgresql:postgresql:1.2.3", + ) +} +""" + + val expectedMillString = """ +import $file.Dependencies, Dependencies.Dependencies + +object foo extends ScalaModule { + def scalaVersion = "2.13.8" + val zioJsonVersion = "2.2.2" + + def ivyDeps = Agg( + ivy"dev.zio::zio-json:$zioJsonVersion", + ivy"io.getquill::quill-jdbc-zio:${Dependencies.quill}", + ivy"dev.cheleb:zio-pravega:0.1.0-RC12", + ivy"org.postgresql:postgresql:42.2.6", + ) +} +""" + + val dependenciesString = + """ +object Dependencies { + val quill = "3.10.0" + def postgresVersion = "42.2.0" +} +""" + + val expectedDependenciesString = + """ +object Dependencies { + val quill = "3.11.0" + def postgresVersion = "42.2.6" +} +""" + + val buildPropertiesString = """ +sbt.version=1.5.5 +""" + + val pluginsSbtString = """ +addSbtPlugin("org.scalafmt" % "sbt-scalafmt" % "2.4.2") +addSbtPlugin("org.scalameta" % "sbt-scalafix" % "0.9.31") +""" + + ///////////////////// + // Latest Versions // + ///////////////////// + + val stubVersions = Map( + (Group("dev.zio"), Artifact("zio")) -> List(Version("1.0.13"), Version("2.0.0")), + (Group("dev.zio"), Artifact("zio-json")) -> List(Version("2.2.2")), + (Group("io.getquill"), Artifact("quill-jdbc-zio")) -> List(Version("3.11.0")), + (Group("dev.cheleb"), Artifact("zio-pravega")) -> List(Version("0.1.0-RC13")), + (Group("org.postgresql"), Artifact("postgresql")) -> List(Version("42.2.6")), + // plugins + (Group("org.scalafmt"), Artifact("sbt-scalafmt")) -> List(Version("2.5.0")), + (Group("org.scalameta"), Artifact("sbt-scalafix")) -> List(Version("0.9.32")), + // build properties + (Group("org.scala-sbt"), Artifact("sbt")) -> List(Version("1.9.9")) + ) + + ////////////////////// + // Expected Results // + ////////////////////// + + val expectedBuildPropertiesString = """ +sbt.version=1.9.9 +""" + + val expectedPluginsSbtString = """ +addSbtPlugin("org.scalafmt" % "sbt-scalafmt" % "2.5.0") +addSbtPlugin("org.scalameta" % "sbt-scalafix" % "0.9.32") +""" + + def spec = + suiteAll("ScalaUpdate") { + test("updates to the latest version of the dependencies") { + for + root <- TestFileHelper.createTempFiles( + "build.sbt" -> buildSbtString, + "project/Dependencies.scala" -> dependenciesString, + "project/plugins.sbt" -> pluginsSbtString, + "project/build.properties" -> buildPropertiesString, + "build.sc" -> buildMillString + ) + _ <- ScalaUpdate.updateAllDependencies(root.toString) + + newBuildSbt <- ZIO.readFile((root / "build.sbt").toString) + newDependencies <- ZIO.readFile((root / "project" / "Dependencies.scala").toString) + newPluginsSbt <- ZIO.readFile((root / "project" / "plugins.sbt").toString) + newBuildProperties <- ZIO.readFile((root / "project" / "build.properties").toString) + newBuildMill <- ZIO.readFile((root / "build.sc").toString) + yield assertTrue( + newBuildSbt == expectedBuildSbtString, + newDependencies == expectedDependenciesString, + newPluginsSbt == expectedPluginsSbtString, + newBuildProperties == expectedBuildPropertiesString, + newBuildMill == expectedMillString + ) + } + }.provide( + Scope.default, + DependencyLoader.live, + Files.live, + ScalaUpdate.layer, + VersionsInMemory.layer(stubVersions) + ) diff --git a/src/test/scala/update/test/utils/TestFileHelper.scala b/src/test/scala/update/test/utils/TestFileHelper.scala new file mode 100644 index 0000000..f8ee3cf --- /dev/null +++ b/src/test/scala/update/test/utils/TestFileHelper.scala @@ -0,0 +1,25 @@ +package update.test.utils + +import zio.nio.file +import zio.nio.file.Path +import zio.* + +import java.io.IOException + +object TestFileHelper: + private def createTempDir: ZIO[Scope, IOException, Path] = + for tempDir <- file.Files.createTempDirectoryScoped(Some("dependency-updater-spec"), Iterable.empty) + yield tempDir + + private def createFile(path: Path, content: String): IO[IOException, Unit] = + ZIO.foreach(path.parent)(file.Files.createDirectories(_)) *> + ZIO.writeFile(path.toString, content) + + def createTempFiles(files: (String, String)*): ZIO[Scope, IOException, Path] = + for + tempDir <- createTempDir + _ <- ZIO.foreachDiscard(files) { case (path, content) => + val filePath = tempDir / path + createFile(filePath, content) + } + yield tempDir