├── .github └── workflows │ └── release.yaml ├── .gitignore ├── .scalafmt.conf ├── LICENSE ├── README.md ├── build.sbt ├── project ├── build.properties └── plugins.sbt └── src ├── main └── scala │ └── update │ ├── Main.scala │ ├── model │ ├── Dependency.scala │ ├── PreRelease.scala │ ├── UpdateOptions.scala │ ├── Version.scala │ └── WithVersions.scala │ ├── services │ ├── Files.scala │ ├── ScalaUpdate.scala │ ├── Versions.scala │ └── dependencies │ │ ├── DependencyLoader.scala │ │ ├── DependencyLoaderCombined.scala │ │ ├── DependencyLoaderSbtVersion.scala │ │ ├── DependencyLoaderScalaSources.scala │ │ ├── DependencyTraverser.scala │ │ └── WithSource.scala │ └── utils │ ├── FileUtils.scala │ ├── Rewriter.scala │ └── UntypedInteractiveCompiler.scala └── test └── scala └── update ├── DependencyLoaderSpec.scala ├── RewriterSpec.scala ├── model └── VersionSpec.scala ├── services └── ScalaUpdateSpec.scala └── test └── utils └── TestFileHelper.scala /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | on: push 2 | 3 | jobs: 4 | build-and-upload: 5 | name: Build and Upload 6 | strategy: 7 | matrix: 8 | os: [ubuntu-latest, macos-latest] 9 | include: 10 | - os: ubuntu-latest 11 | artifact-name: sup-linux-amd 12 | - os: macos-latest 13 | artifact-name: sup-macos-amd 14 | runs-on: ${{ matrix.os }} 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v3 19 | 20 | - name: Setup Scala 21 | uses: olafurpg/setup-scala@v14 22 | with: 23 | java-version: adopt@1.11 24 | 25 | - name: Test for ${{ matrix.os }} 26 | run: sbt test 27 | 28 | - name: Setup GraalVM (for tagged commits) 29 | uses: graalvm/setup-graalvm@v1 30 | if: startsWith(github.ref, 'refs/tags/') 31 | with: 32 | java-version: "21" 33 | github-token: ${{ secrets.GITHUB_TOKEN }} 34 | 35 | - name: Build Native Image (for tagged commits) 36 | run: sbt nativeImage 37 | if: startsWith(github.ref, 'refs/tags/') 38 | 39 | - name: Compress using UPX (not on Windows) 40 | uses: svenstaro/upx-action@v2 41 | if: startsWith(github.ref, 'refs/tags/') && matrix.os != 'windows-latest' 42 | with: 43 | file: target/native-image/scala-update 44 | args: --best --lzma 45 | 46 | - name: Upload Artifact 47 | uses: actions/upload-artifact@v3 48 | if: startsWith(github.ref, 'refs/tags/') 49 | with: 50 | name: ${{ matrix.artifact-name }} 51 | path: target/native-image/scala-update 52 | 53 | - name: Release Binaries 54 | uses: svenstaro/upload-release-action@v2 55 | if: startsWith(github.ref, 'refs/tags/') 56 | with: 57 | repo_token: ${{ secrets.GITHUB_TOKEN }} 58 | file: target/native-image/scala-update 59 | asset_name: ${{ matrix.artifact-name }} 60 | overwrite: true 61 | tag: ${{ github.ref }} 62 | 63 | - name: Prepare Homebrew Formula Update (macOS only) 64 | if: startsWith(github.ref, 'refs/tags/') && matrix.os == 'macos-latest' 65 | run: | 66 | brew tap kitlangton/tap 67 | version=$(echo "${{ github.ref }}" | sed 's/refs\/tags\///') 68 | brew bump-formula-pr kitlangton/tap/scala-update --no-browse --no-audit \ 69 | --url="https://github.com/kitlangton/scala-update/releases/download/${version}/${{ matrix.artifact-name }}" 70 | env: 71 | HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }} 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .bsp 2 | .idea 3 | project/project/target/config-classes 4 | project/target 5 | target 6 | .vscode 7 | .metals 8 | .bloop 9 | project/project 10 | project/metals.sbt 11 | .DS_Store 12 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = "3.8.0" 2 | runner.dialect = scala3 3 | 4 | maxColumn = 120 5 | align.preset = most 6 | align.multiline = false 7 | rewrite.rules = [RedundantBraces, RedundantParens] 8 | rewrite.scala3.convertToNewSyntax = true 9 | rewrite.scala3.removeOptionalBraces = true 10 | docstrings.wrapMaxColumn = 80 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # scala-update 2 | [![Release Artifacts][Badge-SonatypeReleases]][Link-SonatypeReleases] 3 | 4 | Update your Scala dependencies (both libraries and plugins) interactively. [Video Demo](https://twitter.com/kitlangton/status/1541417514823028740). 5 | 6 | ## Installation 7 | 8 | ### Homebrew (Mac Only) 9 | 10 | ```shell 11 | brew install kitlangton/tap/scala-update 12 | ``` 13 | 14 | *If you'd like slightly faster binaries on an M1 mac, install manually with GraalVM (the next step).* 15 | 16 | ### Manually with GraalVM 17 | 18 | #### Prerequisites 19 | 20 | You need GraalVM installed. If you don't have it, you may check their docs [here](https://www.graalvm.org/java/quickstart/). If you're using SDKMAN!, GraalVM images are available to install easily [here](https://sdkman.io/jdks#grl). 21 | ```shell 22 | # See Java versions and pick a GraalVM version, for example 22.1.0.r17-grl 23 | sdk list java 24 | 25 | sdk install java 22.1.0.r17-grl 26 | 27 | # If you haven't set grl version as default, set it for the current terminal session 28 | sdk use java 22.1.0.r17-grl 29 | ``` 30 | 31 | You need `native-image` installed. You can install it with GraalVM updater. 32 | ```shell 33 | gu install native-image 34 | ``` 35 | 36 | #### Building Native Image with GraalVM 37 | 38 | 1. Build the native image with `show graalvm-native-image:packageBin`. 39 | 40 | ```shell 41 | sbt 'show graalvm-native-image:packageBin' 42 | # [info] ~/code/sbt-interactive-update/target/graalvm-native-image/scala-update 43 | ``` 44 | 45 | 2. Move the generated binary onto your `PATH`. For example (in project root directory) 46 | ```shell 47 | # Might need to run with sudo 48 | cp target/graalvm-native-image/scala-update /usr/local/bin 49 | ``` 50 | 51 | ## Usage 52 | 53 | Run the command from within an sbt project folder. 54 | 55 | ```shell 56 | scala-update 57 | ``` 58 | 59 | CleanShot 2022-06-27 at 09 15 23@2x 60 | 61 | The commands are displayed at the bottom of the interactive output. 62 | 63 | Select the libraries you wish to update, then hit `Enter` to update your build files to the selected versions. 64 | 65 | CleanShot 2022-06-27 at 09 15 53@2x 66 | 67 | ### Grouped Depenendcies 68 | 69 | If multiple dependencies share a single version, they will be grouped. 70 | 71 | CleanShot 2022-06-27 at 09 18 15@2x 72 | 73 | ### Multiple Versions 74 | 75 | If a dependency has multiple possible update version—for instance, a new major version and a new minor version—then you can select which version to upgrade to. 76 | 77 | CleanShot 2022-06-27 at 09 20 23@2x 78 | 79 | ## FAQ 80 | 81 | ### How did you make the interactive CLI? 82 | 83 | I have another library, [zio-tui](https://github.com/kitlangton/zio-tui), for creating interactive command line programs just like this one. 84 | 85 | [Badge-SonatypeReleases]: https://img.shields.io/nexus/r/https/oss.sonatype.org/io.github.kitlangton/scala-update_2.13.svg "Sonatype Releases" 86 | [Badge-SonatypeSnapshots]: https://img.shields.io/nexus/s/https/oss.sonatype.org/io.github.kitlangton/scala-update_2.13.svg "Sonatype Snapshots" 87 | [Link-SonatypeSnapshots]: https://oss.sonatype.org/content/repositories/snapshots/io/github/kitlangton/scala-update_2.13/ "Sonatype Snapshots" 88 | [Link-SonatypeReleases]: https://oss.sonatype.org/content/repositories/releases/io/github/kitlangton/scala-update_2.13/ "Sonatype Releases" 89 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | ThisBuild / version := "0.1.0-SNAPSHOT" 2 | 3 | ThisBuild / scalaVersion := "3.4.1" 4 | 5 | val zioVersion = "2.0.21" 6 | 7 | // /Users/kit/code/terminus 8 | //val terminusLocal = ProjectRef(file("/Users/kit/code/terminus"), "terminusZioJVM") 9 | 10 | lazy val root = (project in file(".")) 11 | .enablePlugins(JavaAppPackaging) 12 | .enablePlugins(NativeImagePlugin) 13 | .settings( 14 | name := "scala-update", 15 | libraryDependencies ++= Seq( 16 | "org.scala-lang" %% "scala3-compiler" % "3.4.1", 17 | "io.get-coursier" % "interface" % "1.0.19", 18 | "com.lihaoyi" %% "pprint" % "0.8.1", 19 | "dev.zio" %% "zio-nio" % "2.0.2", 20 | "dev.zio" %% "zio" % zioVersion, 21 | "dev.zio" %% "zio-streams" % zioVersion, 22 | "dev.zio" %% "zio-test" % zioVersion % Test, 23 | "io.github.kitlangton" %% "terminus-zio" % "0.0.9" 24 | ), 25 | mainClass := Some("update.Main"), 26 | nativeImageVersion := "21.0.2", 27 | nativeImageJvm := "graalvm-java21" 28 | ) 29 | // .dependsOn(terminusLocal) 30 | 31 | Global / onChangedBuildSource := ReloadOnSourceChanges 32 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version = 1.9.9 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.16") 2 | //addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.0.0") 3 | //addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "2.0.1") 4 | //addSbtPlugin("org.scalameta" % "sbt-native-image" % "0.6.0") 5 | addSbtPlugin("org.scalameta" % "sbt-native-image" % "0.3.4") 6 | addSbtPlugin("org.portable-scala" % "sbt-scala-native-crossproject" % "1.3.2") 7 | addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.0-RC2") 8 | -------------------------------------------------------------------------------- /src/main/scala/update/Main.scala: -------------------------------------------------------------------------------- 1 | package update 2 | import update.services.* 3 | import update.services.dependencies.* 4 | import update.model.* 5 | import zio.* 6 | import terminus.* 7 | import terminus.KeyCode.{Character, Down} 8 | import update.model.Dependency 9 | import zio.stream.ZStream 10 | import terminus.components.ScrollList 11 | import update.utils.Rewriter 12 | 13 | import scala.annotation.tailrec 14 | 15 | final case class DependencyState( 16 | dependencies: NonEmptyChunk[Dependency], 17 | sourceInfo: SourceInfo, 18 | currentVersion: Version, 19 | updateOptions: UpdateOptions, 20 | selectedVersionType: VersionType 21 | ): 22 | @tailrec 23 | def nextVersion: DependencyState = 24 | val nextVersionType = selectedVersionType.next 25 | val next = copy(selectedVersionType = nextVersionType) 26 | if updateOptions.hasVersionType(nextVersionType) then next 27 | else next.nextVersion 28 | 29 | @tailrec 30 | def previousVersion: DependencyState = 31 | val previousVersionType = selectedVersionType.prev 32 | val previous = copy(selectedVersionType = previousVersionType) 33 | if updateOptions.hasVersionType(previousVersionType) then previous 34 | else previous.previousVersion 35 | 36 | def selectedVersion: Version = 37 | selectedVersionType match 38 | case VersionType.Major => updateOptions.major.get 39 | case VersionType.Minor => updateOptions.minor.get 40 | case VersionType.Patch => updateOptions.patch.get 41 | case VersionType.PreRelease => updateOptions.preRelease.get 42 | 43 | object DependencyState: 44 | def from(dependency: WithVersions[WithSource[Dependency]]): Option[DependencyState] = 45 | val dep = dependency.value.value 46 | val versions = dependency.versions 47 | val current = dep.version 48 | val options = UpdateOptions.getOptions(current, versions) 49 | val sourceInfo = dependency.value.sourceInfo 50 | val maybeVersionType = options match 51 | case UpdateOptions(Some(_), _, _, _) => Some(VersionType.Major) 52 | case UpdateOptions(_, Some(_), _, _) => Some(VersionType.Minor) 53 | case UpdateOptions(_, _, Some(_), _) => Some(VersionType.Patch) 54 | case UpdateOptions(_, _, _, Some(_)) => Some(VersionType.PreRelease) 55 | case _ => None 56 | maybeVersionType.map(versionType => DependencyState(NonEmptyChunk(dep), sourceInfo, current, options, versionType)) 57 | 58 | object ScalaUpdateCLI extends TerminalAppZIO[List[DependencyState]]: 59 | enum Message: 60 | case LoadDependencies(dependencies: List[WithSource[Dependency]]) 61 | case LoadVersions(dependency: WithVersions[WithSource[Dependency]]) 62 | 63 | sealed trait State 64 | object State: 65 | case object Loading extends State 66 | case class Loaded( 67 | totalDependencyCount: Int, 68 | totalFinishedCount: Int, 69 | dependencies: List[DependencyState], 70 | selected: Set[SourceInfo], 71 | currentIndex: Int, 72 | showGroup: Boolean = false 73 | ) extends State: 74 | def moveUp: Loaded = 75 | copy(currentIndex = currentIndex - 1).clampIndex 76 | 77 | def moveDown: Loaded = 78 | copy(currentIndex = currentIndex + 1).clampIndex 79 | 80 | def nextVersion: Loaded = 81 | val newDependencies = dependencies.updated(currentIndex, dependencies(currentIndex).nextVersion) 82 | copy(dependencies = newDependencies) 83 | 84 | def previousVersion: Loaded = 85 | val newDependencies = dependencies.updated(currentIndex, dependencies(currentIndex).previousVersion) 86 | copy(dependencies = newDependencies) 87 | 88 | def incrementFinishedCount: Loaded = 89 | copy(totalFinishedCount = totalFinishedCount + 1) 90 | 91 | def withVersions(dep: WithVersions[WithSource[Dependency]]): Loaded = 92 | DependencyState.from(dep) match 93 | case Some(state) => 94 | // if existing with same sourceInfo, append dependency 95 | // otherwise add new state 96 | val updated = dependencies.find(_.sourceInfo == state.sourceInfo) match 97 | case Some(existing) => 98 | val newDependencies = existing.dependencies ++ state.dependencies 99 | dependencies.updated(dependencies.indexOf(existing), existing.copy(dependencies = newDependencies)) 100 | case None => 101 | dependencies.appended(state) 102 | copy(dependencies = updated.sortBy(_.dependencies.head)) 103 | 104 | case None => 105 | this 106 | 107 | def toggleCurrent: Loaded = 108 | if selected.contains(currentSourceInfo) then copy(selected = selected - currentSourceInfo) 109 | else copy(selected = selected + currentSourceInfo) 110 | 111 | def currentSourceInfo: SourceInfo = 112 | dependencies(currentIndex).sourceInfo 113 | 114 | def selectedStates: List[DependencyState] = 115 | dependencies.filter(dep => selected.contains(dep.sourceInfo)) 116 | 117 | // if not all selected, select all 118 | // if all selected, deselect all 119 | def toggleAll: Loaded = 120 | if selected.size == dependencies.size then copy(selected = Set.empty) 121 | else copy(selected = dependencies.map(_.sourceInfo).toSet) 122 | 123 | private def clampIndex: Loaded = 124 | val nextSelected = 125 | if currentIndex < 0 then dependencies.length - 1 126 | else if currentIndex >= dependencies.length then 0 127 | else currentIndex 128 | copy(currentIndex = nextSelected) 129 | end Loaded 130 | end State 131 | 132 | def renderDependency( 133 | state: State.Loaded, 134 | firstColumnWidth: Int, 135 | maxCurrentVersionWidth: Int, 136 | dependencyState: DependencyState, 137 | index: Int 138 | ): View = 139 | val dependencies = dependencyState.dependencies 140 | val sourceInfo = dependencyState.sourceInfo 141 | val isCurrent = state.currentIndex == index 142 | val isSelected = state.selected.contains(sourceInfo) 143 | 144 | val selectedVersionType = dependencyState.selectedVersionType 145 | // val versions = dependencyState.updateOptions.versions 146 | val updateOptions = dependencyState.updateOptions 147 | def optionView(versionType: VersionType, version: Option[Version], color: Color = Color.Green) = 148 | version.map { version => 149 | if versionType == selectedVersionType then 150 | View 151 | .text(version.toString) 152 | .color(color) 153 | .underline(isSelected) 154 | else View.text(version.toString).color(color).dim 155 | } 156 | 157 | val versionsView = 158 | View.horizontal( 159 | optionView(VersionType.Major, updateOptions.major), 160 | optionView(VersionType.Minor, updateOptions.minor), 161 | optionView(VersionType.Patch, updateOptions.patch), 162 | optionView(VersionType.PreRelease, updateOptions.preRelease, Color.Magenta), 163 | Option.when(isCurrent)( 164 | View.text(dependencyState.selectedVersionType.toString).yellow 165 | ) 166 | ) 167 | 168 | def renderSingle(dependency: Dependency) = 169 | View 170 | .horizontal( 171 | Option.when(state.showGroup)( 172 | Seq( 173 | View.text(dependency.group.value), 174 | View.text(if dependency.isJava then "%" else "%%").dim 175 | ) 176 | ), 177 | View.text(dependency.artifact.value) 178 | ) 179 | .width(firstColumnWidth) 180 | 181 | View 182 | .horizontal( 183 | if isCurrent then View.text("❯").bold else View.text(" ").dim, 184 | if isSelected then View.text("◉").bold.green else View.text("○").dim, 185 | View.vertical( 186 | dependencies.map(renderSingle).toList 187 | ), 188 | View 189 | .text(dependencyState.currentVersion.toString) 190 | .dim 191 | .width(maxCurrentVersionWidth), 192 | View.text("→").dim, 193 | versionsView 194 | ) 195 | 196 | val divider: View = 197 | View 198 | .geometryReader { size => 199 | View.text("─" * size.width) 200 | } 201 | .fillHorizontal 202 | .color(Color.Blue) 203 | 204 | def header(state: State) = 205 | val stats: View = state match 206 | case State.Loading => 207 | View.text("Loading...") 208 | case loaded: State.Loaded => 209 | View.horizontal( 210 | s"${loaded.totalFinishedCount}", 211 | View.text(s"/ ${loaded.totalDependencyCount} analyzed").dim, 212 | View.text("•").dim, 213 | View.text(s"${loaded.dependencies.flatMap(_.dependencies).length}"), 214 | View.text(s"updates").dim 215 | ) 216 | View 217 | .geometryReader { size => 218 | View 219 | .vertical( 220 | View.horizontal( 221 | View.text("SCALA UPDATE").bold, 222 | View.spacer, 223 | stats 224 | ), 225 | "─" * size.width 226 | ) 227 | } 228 | .fillHorizontal 229 | .color(Color.Blue) 230 | 231 | // space toggle a toggle all ↑/↓ move up/dow g show groups q quit 232 | 233 | def renderCommand(key: String, description: String): View = 234 | View.horizontal(View.text(key), View.text(description).dim).blue 235 | 236 | def renderCommands(loaded: State.Loaded) = 237 | View.horizontal(2)( 238 | renderCommand("space", "toggle"), 239 | renderCommand("a", "toggle all"), 240 | renderCommand("↑/↓", "up/down"), 241 | renderCommand("g", if loaded.showGroup then "hide groups" else "show groups"), 242 | renderCommand("q", "quit") 243 | ) 244 | 245 | override def render(state: State): View = 246 | val body = state match 247 | case loaded: State.Loaded => 248 | val maxGroupArtifactWidth = loaded.dependencies 249 | .flatMap(_.dependencies) 250 | .map { dep => 251 | val group = if loaded.showGroup then s"${dep.group.value} ${if dep.isJava then "%" else "%%"} " else "" 252 | s"$group${dep.artifact.value}".length 253 | } 254 | .maxOption 255 | .getOrElse(0) 256 | 257 | val maxCurrentVersionWidth = loaded.dependencies 258 | .map(_.currentVersion.toString.length) 259 | .maxOption 260 | .getOrElse(0) 261 | 262 | View.vertical( // 263 | ScrollList( 264 | loaded.dependencies.zipWithIndex.map { (dep, index) => 265 | renderDependency(loaded, maxGroupArtifactWidth, maxCurrentVersionWidth, dep, index) 266 | }, 267 | loaded.currentIndex 268 | ).fillHorizontal, 269 | divider.dim, 270 | renderCommands(loaded) 271 | ) 272 | 273 | case State.Loading => 274 | View.text("Loading...") 275 | 276 | View.vertical( 277 | header(state), 278 | body 279 | ) 280 | 281 | override def update(state: State, input: KeyCode | Message): Handled = 282 | state match 283 | case State.Loading => 284 | input match 285 | case KeyCode.Exit | KeyCode.Character('q') => 286 | Handled.Exit 287 | 288 | case Message.LoadDependencies(dependencies) => 289 | Handled.Continue(State.Loaded(dependencies.length, 0, List.empty, Set.empty, 0)) 290 | 291 | case _ => Handled.Continue(state) 292 | 293 | case state: State.Loaded => 294 | input match 295 | case Message.LoadVersions(dep) => 296 | Handled.Continue(state.withVersions(dep).incrementFinishedCount) 297 | 298 | case KeyCode.Up | Character('k') => 299 | Handled.Continue(state.moveUp) 300 | case KeyCode.Down | Character('j') => 301 | Handled.Continue(state.moveDown) 302 | case KeyCode.Right => 303 | Handled.Continue(state.nextVersion) 304 | case KeyCode.Left => 305 | Handled.Continue(state.previousVersion) 306 | case Character('g') => 307 | Handled.Continue(state.copy(showGroup = !state.showGroup)) 308 | case Character(' ') => 309 | Handled.Continue(state.toggleCurrent) 310 | case Character('a') => 311 | Handled.Continue(state.toggleAll) 312 | case KeyCode.Enter => 313 | Handled.Done(state.selectedStates) 314 | case KeyCode.Exit | Character('q') => 315 | Handled.Exit 316 | case _ => 317 | Handled.Continue(state) 318 | 319 | object Main extends ZIOAppDefault: 320 | 321 | val dependencyStream: ZStream[DependencyLoader, Throwable, List[WithSource[Dependency]]] = 322 | ZStream 323 | .fromZIO { 324 | ZIO.serviceWithZIO[DependencyLoader](_.getDependencies(".")) 325 | } 326 | 327 | def loadVersionsStream( 328 | dependencies: List[WithSource[Dependency]] 329 | ): ZStream[Versions, Throwable, ScalaUpdateCLI.Message] = 330 | ZStream.fromIterable(dependencies).mapZIO { dep => 331 | ZIO.serviceWithZIO[Versions](_.getVersions(dep.value)).map { versions => 332 | ScalaUpdateCLI.Message.LoadVersions(WithVersions(dep, versions)) 333 | } 334 | } 335 | 336 | val versionStream: ZStream[Versions & DependencyLoader, Throwable, ScalaUpdateCLI.Message] = 337 | dependencyStream 338 | .flatMap { dependencies => 339 | ZStream.succeed(ScalaUpdateCLI.Message.LoadDependencies(dependencies)) merge 340 | loadVersionsStream(dependencies) 341 | } 342 | 343 | val program = 344 | for 345 | env <- ZIO.environment[Versions & DependencyLoader] 346 | selected <- ScalaUpdateCLI.run( 347 | ScalaUpdateCLI.State.Loading, 348 | versionStream.provideEnvironment(env).orDie 349 | ) 350 | _ <- ZIO.foreachDiscard(selected) { states => 351 | val versionWithSource = states.map { state => 352 | WithSource(state.selectedVersion, state.sourceInfo) 353 | } 354 | 355 | writeToSource(versionWithSource) 356 | } 357 | yield () 358 | 359 | private def writeToSource(selectedVersions: List[WithSource[Version]]): Task[Unit] = 360 | val groupedBySourceFile = selectedVersions.groupBy(_.sourceInfo.path) 361 | ZIO.foreachParDiscard(groupedBySourceFile) { case (path, versions) => 362 | rewriteSourceFile(path, versions) 363 | } 364 | 365 | private def rewriteSourceFile( 366 | path: String, 367 | versions: List[WithSource[Version]] 368 | ): Task[Unit] = 369 | for 370 | sourceCode <- ZIO.readFile(path) 371 | patches = versions.map { version => 372 | Rewriter.Patch( 373 | start = version.sourceInfo.start, 374 | end = version.sourceInfo.end, 375 | replacement = version.value.toString 376 | ) 377 | } 378 | updatedSourceCode = Rewriter.rewrite(sourceCode, patches) 379 | _ <- ZIO.writeFile(path, updatedSourceCode) 380 | yield () 381 | 382 | val run = 383 | // ZIO 384 | // .serviceWithZIO[ScalaUpdate](_.updateAllDependencies(".")) 385 | program 386 | .provide( 387 | ScalaUpdate.layer, 388 | Versions.live, 389 | DependencyLoader.live, 390 | Files.live 391 | ) 392 | -------------------------------------------------------------------------------- /src/main/scala/update/model/Dependency.scala: -------------------------------------------------------------------------------- 1 | package update.model 2 | 3 | final case class Group(value: String) extends AnyVal 4 | final case class Artifact(value: String) extends AnyVal 5 | 6 | // group %% artifact % version 7 | final case class Dependency( 8 | group: Group, 9 | artifact: Artifact, 10 | version: Version, 11 | isJava: Boolean = true 12 | ) 13 | 14 | object Dependency: 15 | def apply(group: String, artifact: String, version: String, isJava: Boolean): Dependency = 16 | new Dependency(Group(group), Artifact(artifact), Version(version), isJava) 17 | 18 | def scalaVersion(versionString: String): Dependency = 19 | val version = Version(versionString) 20 | if version.majorVersion.contains(3) 21 | then Dependency(Group("org.scala-lang"), Artifact("scala3-library"), version) 22 | else Dependency(Group("org.scala-lang"), Artifact("scala-library"), version) 23 | 24 | implicit val dependencyOrder: Ordering[Dependency] = 25 | Ordering.by(d => (d.group.value, d.artifact.value, d.version)) 26 | 27 | def sbt(version: String): Dependency = Dependency(sbtGroup, sbtArtifact, Version(version)) 28 | 29 | private val sbtGroup: Group = Group("org.scala-sbt") 30 | private val sbtArtifact: Artifact = Artifact("sbt") 31 | def isSbt(group: Group, artifact: Artifact): Boolean = group == sbtGroup && artifact == sbtArtifact 32 | -------------------------------------------------------------------------------- /src/main/scala/update/model/PreRelease.scala: -------------------------------------------------------------------------------- 1 | package update.model 2 | 3 | import scala.math.Ordered.orderingToOrdered 4 | 5 | enum PreRelease: 6 | case RC(n: Int) extends PreRelease 7 | case M(n: Int) extends PreRelease 8 | case Alpha(n: Option[Int]) extends PreRelease 9 | case Beta(n: Option[Int]) extends PreRelease 10 | 11 | override def toString: String = this match 12 | case RC(n) => s"RC$n" 13 | case M(n) => s"M$n" 14 | case Alpha(Some(n)) => s"alpha.$n" 15 | case Alpha(None) => "alpha" 16 | case Beta(Some(n)) => s"beta.$n" 17 | case Beta(None) => "beta" 18 | 19 | object PreRelease: 20 | import Version.MatchInt 21 | 22 | // alpha < beta < M < RC 23 | def compare(x: PreRelease, y: PreRelease): Int = 24 | def ordinal = (p: PreRelease) => 25 | p match 26 | case PreRelease.RC(_) => 4 27 | case PreRelease.M(_) => 3 28 | case PreRelease.Beta(_) => 2 29 | case PreRelease.Alpha(_) => 1 30 | 31 | def number(p: PreRelease): Int = p match 32 | case PreRelease.RC(n) => n 33 | case PreRelease.M(n) => n 34 | case PreRelease.Beta(Some(n)) => n 35 | case PreRelease.Alpha(Some(n)) => n 36 | case _ => 0 37 | 38 | (ordinal(x), number(x)) compare (ordinal(y), number(y)) 39 | 40 | def parse(value: String): Option[PreRelease] = 41 | val Re = raw"([A-Za-z]+)(\d+)(\w+)?".r 42 | value match 43 | case Re("RC", n, _) => Some(RC(n.toInt)) 44 | case Re("M", n, _) => Some(M(n.toInt)) 45 | case "alpha" => Some(Alpha(None)) 46 | case s"alpha.${MatchInt(n)}" => Some(Alpha(Some(n))) 47 | case "beta" => Some(Beta(None)) 48 | case s"beta.${MatchInt(n)}" => Some(Beta(Some(n))) 49 | case _ => None 50 | 51 | given ordering: Ordering[PreRelease] = (x: PreRelease, y: PreRelease) => PreRelease.compare(x, y) 52 | -------------------------------------------------------------------------------- /src/main/scala/update/model/UpdateOptions.scala: -------------------------------------------------------------------------------- 1 | package update.model 2 | import Ordered.given 3 | 4 | enum VersionType: 5 | case Major 6 | case Minor 7 | case Patch 8 | case PreRelease 9 | 10 | // wrap around 11 | def next: VersionType = VersionType.fromOrdinal((ordinal + 1) % VersionType.values.length) 12 | def prev: VersionType = VersionType.fromOrdinal((ordinal - 1 + VersionType.values.length) % VersionType.values.length) 13 | 14 | final case class UpdateOptions( 15 | major: Option[Version], 16 | minor: Option[Version], 17 | patch: Option[Version], 18 | preRelease: Option[Version] 19 | ): 20 | 21 | def hasMajor: Boolean = major.isDefined 22 | def hasMinor: Boolean = minor.isDefined 23 | def hasPatch: Boolean = patch.isDefined 24 | def hasPreRelease: Boolean = preRelease.isDefined 25 | def hasVersionType(versionType: VersionType): Boolean = 26 | versionType match 27 | case VersionType.Major => hasMajor 28 | case VersionType.Minor => hasMinor 29 | case VersionType.Patch => hasPatch 30 | case VersionType.PreRelease => hasPreRelease 31 | 32 | def newestVersion: Option[Version] = 33 | major.orElse(minor).orElse(patch).orElse(preRelease) 34 | 35 | def allVersions: List[Version] = major.toList ++ minor.toList ++ patch.toList ++ preRelease.toList 36 | 37 | def isEmpty: Boolean = major.isEmpty && minor.isEmpty && patch.isEmpty && preRelease.isEmpty 38 | 39 | def isNonEmpty: Boolean = !isEmpty 40 | 41 | object UpdateOptions: 42 | 43 | def getOptions(current: Version, available0: List[Version]): UpdateOptions = 44 | current match 45 | case v: Version.SemVer => getOptions(v, available0) 46 | case _ => UpdateOptions(None, None, None, None) 47 | 48 | def getOptions(current: Version.SemVer, available0: List[Version]): UpdateOptions = 49 | val available = available0.collect { case v: Version.SemVer => v } 50 | val major = current.major 51 | val minor = current.minor 52 | val patch = current.patch 53 | 54 | val allNewerVersions = available.filter(_ > current).sorted 55 | 56 | val majorVersion = allNewerVersions 57 | .filter(v => ((current.preRelease.isDefined && v.major == major) || v.major > major) && v.preRelease.isEmpty) 58 | .lastOption 59 | 60 | val minorVersion = allNewerVersions 61 | .filter(v => v.major == major && v.minor > minor && v.preRelease.isEmpty) 62 | .lastOption 63 | .filterNot(majorVersion.contains) 64 | 65 | val patchVersion = allNewerVersions 66 | .filter(v => v.major == major && v.minor == minor && v.patch > patch && v.preRelease.isEmpty) 67 | .lastOption 68 | .filterNot(v => majorVersion.contains(v) || minorVersion.contains(v)) 69 | 70 | val preReleaseVersions = 71 | allNewerVersions 72 | .filter(_.preRelease.isDefined) 73 | .filterNot { version => 74 | patchVersion.exists(_ >= version) || 75 | minorVersion.exists(_ >= version) || 76 | majorVersion.exists(_ >= version) 77 | } 78 | .lastOption 79 | 80 | UpdateOptions(majorVersion, minorVersion, patchVersion, preReleaseVersions) 81 | -------------------------------------------------------------------------------- /src/main/scala/update/model/Version.scala: -------------------------------------------------------------------------------- 1 | package update.model 2 | 3 | import scala.math.Ordered.orderingToOrdered 4 | 5 | enum Version: 6 | case SemVer(major: Int, minor: Int, patch: Int, preRelease: Option[PreRelease]) 7 | case Other(value: String) 8 | 9 | def majorVersion: Option[Int] = this match 10 | case SemVer(major, _, _, _) => Some(major) 11 | case Other(_) => None 12 | 13 | override def toString: String = this match 14 | case SemVer(major, minor, patch, preRelease) => 15 | val preReleaseStr = preRelease.fold("")(pr => s"-$pr") 16 | s"$major.$minor.$patch$preReleaseStr" 17 | case Other(value) => value 18 | 19 | def isPreRelease: Boolean = this match 20 | case SemVer(_, _, _, Some(_)) => true 21 | case Other(_) => true 22 | case _ => false 23 | 24 | object Version: 25 | 26 | object MatchInt: 27 | def unapply(value: String): Option[Int] = 28 | value.toIntOption 29 | 30 | object MatchPreRelease: 31 | def unapply(value: String): Option[PreRelease] = 32 | PreRelease.parse(value) 33 | 34 | def apply(string: String): Version = 35 | string match 36 | // 1.1.1 37 | case s"${MatchInt(major)}.${MatchInt(minor)}.${MatchInt(patch)}" => 38 | SemVer(major, minor, patch, None) 39 | 40 | // 1.1.1-RC1 41 | case s"${MatchInt(major)}.${MatchInt(minor)}.${MatchInt(patch)}-${MatchPreRelease(preRelease)}" => 42 | SemVer(major, minor, patch, Some(preRelease)) 43 | 44 | // 1.1 45 | case s"${MatchInt(major)}.${MatchInt(minor)}" => 46 | SemVer(major, minor, 0, None) 47 | 48 | // 2.0-RC1 49 | case s"${MatchInt(major)}.${MatchInt(minor)}-${MatchPreRelease(preRelease)}" => 50 | SemVer(major, minor, 0, Some(preRelease)) 51 | 52 | case other => 53 | Other(other) 54 | 55 | // None should be considered greater than any pre-release 56 | given Ordering[Option[PreRelease]] = 57 | new Ordering[Option[PreRelease]]: 58 | def compare(x: Option[PreRelease], y: Option[PreRelease]): Int = 59 | (x, y) match 60 | case (Some(_), None) => -1 61 | case (None, Some(_)) => 1 62 | case (Some(pr1), Some(pr2)) => pr1 compare pr2 63 | case (None, None) => 0 64 | 65 | given Ordering[Version] with 66 | def compare(x: Version, y: Version): Int = 67 | (x, y) match 68 | case (SemVer(m1, n1, p1, pr1), SemVer(m2, n2, p2, pr2)) => 69 | summon[Ordering[(Int, Int, Int, Option[PreRelease])]] 70 | .compare((m1, n1, p1, pr1), (m2, n2, p2, pr2)) 71 | case (SemVer(_, _, _, _), Other(_)) => -1 72 | case (Other(_), SemVer(_, _, _, _)) => 1 73 | case (Other(x), Other(y)) => x compare y 74 | -------------------------------------------------------------------------------- /src/main/scala/update/model/WithVersions.scala: -------------------------------------------------------------------------------- 1 | package update.model 2 | 3 | final case class WithVersions[A](value: A, versions: List[Version]) 4 | -------------------------------------------------------------------------------- /src/main/scala/update/services/Files.scala: -------------------------------------------------------------------------------- 1 | package update.services 2 | 3 | import update.utils.FileUtils 4 | import zio.* 5 | import zio.nio.file.Path 6 | import zio.stream.* 7 | 8 | trait Files: 9 | def allBuildScalaPaths(path: String): Task[Chunk[Path]] 10 | 11 | object Files: 12 | val live = ZLayer.succeed(FilesLive) 13 | 14 | case object FilesLive extends Files: 15 | override def allBuildScalaPaths(root: String): Task[Chunk[Path]] = 16 | val buildProperties = "build.properties" 17 | 18 | val rootPath = Path(root) 19 | // Build files with Sbt1 dialect content 20 | val projectScalaPaths = FileUtils.allScalaFiles(rootPath / "project") 21 | val buildSbtPath = ZStream.succeed(rootPath / "build.sbt") 22 | val buildMillPath = FileUtils.allMillFiles(rootPath) 23 | val pluginsPath = ZStream.succeed(rootPath / "project" / "plugins.sbt") 24 | // A .properties file 25 | val sbtPropertiesFilePath = ZStream.succeed(rootPath / "project" / buildProperties) 26 | // ++ sbtPropertiesFilePath 27 | 28 | val allSourcePaths = (projectScalaPaths ++ buildSbtPath ++ pluginsPath ++ buildMillPath) 29 | .filterZIO(path => zio.nio.file.Files.exists(path)) 30 | 31 | allSourcePaths.runCollect 32 | -------------------------------------------------------------------------------- /src/main/scala/update/services/ScalaUpdate.scala: -------------------------------------------------------------------------------- 1 | package update.services 2 | 3 | import update.model.* 4 | import update.utils.Rewriter 5 | import update.* 6 | import update.services.dependencies.* 7 | import zio.* 8 | 9 | final case class ScalaUpdate(dependencyLoader: DependencyLoader, versions: Versions): 10 | 11 | // - Find all dependencies with their current versions 12 | // - Load all versions for each dependency 13 | // - Find the most recent patch/minor/major/pre-release version for each dependency 14 | // - THE USER will then select a version for each dependency (or not) 15 | // - Gather all selected versions with their source positions 16 | // - Group by source file 17 | // - Rewrite each source file with the new versions 18 | def updateAllDependencies(root: String): Task[List[WithVersions[WithSource[Dependency]]]] = 19 | for 20 | dependencies <- dependencyLoader.getDependencies(root) 21 | withVersions <- ZIO.foreachPar(dependencies)(getVersionsForDependency) 22 | _ <- ZIO.foreachDiscard(withVersions) { withVersions => 23 | ZIO.succeed( 24 | println( 25 | s"${withVersions.value.value} in ${withVersions.value.sourceInfo.path}\n versions: ${withVersions.versions}" 26 | ) 27 | ) 28 | } 29 | latestVersions = withVersions.flatMap(getLatestVersion).distinct 30 | _ <- ZIO.foreachDiscard(latestVersions) { version => 31 | ZIO.succeed(println(s"${version.value} in ${version.sourceInfo.path}")) 32 | } 33 | _ <- writeToSource(latestVersions) 34 | yield withVersions 35 | 36 | private def writeToSource(selectedVersions: List[WithSource[Version]]): Task[Unit] = 37 | val groupedBySourceFile = selectedVersions.groupBy(_.sourceInfo.path) 38 | ZIO.foreachParDiscard(groupedBySourceFile) { case (path, versions) => 39 | rewriteSourceFile(path, versions) 40 | } 41 | 42 | private def rewriteSourceFile( 43 | path: String, 44 | versions: List[WithSource[Version]] 45 | ): Task[Unit] = 46 | for 47 | sourceCode <- ZIO.readFile(path) 48 | patches = versions.map { version => 49 | Rewriter.Patch( 50 | start = version.sourceInfo.start, 51 | end = version.sourceInfo.end, 52 | replacement = version.value.toString 53 | ) 54 | } 55 | updatedSourceCode = Rewriter.rewrite(sourceCode, patches) 56 | _ <- ZIO.writeFile(path, updatedSourceCode) 57 | yield () 58 | 59 | private def getLatestVersion(withVersions: WithVersions[WithSource[Dependency]]): Option[WithSource[Version]] = 60 | withVersions.versions.filterNot(_.isPreRelease).maxOption.map { latest => 61 | WithSource(latest, withVersions.value.sourceInfo) 62 | } 63 | 64 | private def getVersionsForDependency( 65 | dependency: WithSource[Dependency] 66 | ): Task[WithVersions[WithSource[Dependency]]] = 67 | for versions <- versions.getVersions(dependency.value) 68 | yield WithVersions(dependency, versions) 69 | 70 | object ScalaUpdate: 71 | val layer = ZLayer.fromFunction(ScalaUpdate.apply) 72 | 73 | def updateAllDependencies(root: String): RIO[ScalaUpdate, List[WithVersions[WithSource[Dependency]]]] = 74 | ZIO.serviceWithZIO[ScalaUpdate](_.updateAllDependencies(root)) 75 | -------------------------------------------------------------------------------- /src/main/scala/update/services/Versions.scala: -------------------------------------------------------------------------------- 1 | package update.services 2 | 3 | import coursierapi.Complete 4 | import update.model.* 5 | import zio.* 6 | 7 | import scala.jdk.CollectionConverters.* 8 | 9 | trait Versions: 10 | def getVersions(group: Group, artifact: Artifact, isJava: Boolean): Task[List[Version]] 11 | 12 | def getVersions(dependency: Dependency): Task[List[Version]] = 13 | getVersions(dependency.group, dependency.artifact, dependency.isJava) 14 | 15 | object Versions: 16 | val live = ZLayer.fromFunction(() => VersionsLive()) 17 | 18 | final case class VersionsLive() extends Versions: 19 | val cache = coursierapi.Cache.create() 20 | 21 | def getVersions(group: Group, artifact: Artifact, isJava: Boolean): Task[List[Version]] = 22 | val coursierVersions = coursierapi.Versions.create().withCache(cache) 23 | val coursierComplete = Complete.create() 24 | ZIO.attemptBlocking { 25 | 26 | // If %% is used, we need to expand the artifact to include the scala version 27 | // so we can use Coursier's completion API to get the full list of artifacts 28 | // where the scala version is included in the artifact name, e.g. cats-core_2.13. 29 | // Otherwise, for "isJava" artifacts, we can just use the artifact name as is 30 | val expandedArtifacts = 31 | Option( 32 | coursierComplete 33 | .withInput(s"${group.value}:${artifact.value}_") 34 | .complete() 35 | .getCompletions 36 | .asScala 37 | ).filter(_.nonEmpty) 38 | .getOrElse(List(artifact.value)) 39 | 40 | val versions = 41 | expandedArtifacts.toList.flatMap { artifact => 42 | coursierVersions 43 | .withModule(coursierapi.Module.of(group.value, artifact)) 44 | .versions() 45 | .getMergedListings 46 | .getAvailable 47 | .asScala 48 | .map(v => Version(v)) 49 | .toList 50 | }.distinct 51 | 52 | versions 53 | } 54 | 55 | final case class VersionsInMemory(versions: Map[(Group, Artifact), List[Version]]) extends Versions: 56 | override def getVersions(group: Group, artifact: Artifact, isJava: Boolean): Task[List[Version]] = 57 | ZIO.succeed(versions.getOrElse((group, artifact), Nil)) 58 | 59 | object VersionsInMemory: 60 | def layer(versions: Map[(Group, Artifact), List[Version]]): ULayer[Versions] = 61 | ZLayer.succeed(VersionsInMemory(versions)) 62 | 63 | //object TestVersions extends ZIOAppDefault: 64 | // 65 | // val tests = List( 66 | //// Dependency(Group("dev.zio"), Artifact("zio"), Version("1.0.0")), 67 | //// Dependency(Group("org.typelevel"), Artifact("cats-core"), Version("2.0.0")), 68 | //// Dependency(Group("com.github.sbt"), Artifact("sbt-native-packager"), Version("1.9.11")), 69 | //// Dependency(Group("com.github.sbt"), Artifact("sbt-ci-release"), Version("1.5.11")), 70 | //// Dependency(Group("ch.epfl.scala"), Artifact("sbt-scalafix"), Version("0.10.4")), 71 | //// Dependency(Group("org.scalameta"), Artifact("sbt-scalafmt"), Version("0.10.4")), 72 | //// Dependency(Group("org.scala-sbt"), Artifact("sbt"), Version("1.5.5")), 73 | // // "org.scala-lang" %% "scala3-compiler" % "0.5.4" 74 | //// Dependency(Group("org.scala-lang"), Artifact("scala3-compiler"), Version("0.5.4")), 75 | // // io.get-coursier 76 | //// Dependency(Group("io.get-coursier"), Artifact("interface"), Version("2.0.0-RC6")) 77 | //// com.github.sbt % sbt-native-packager 78 | // Dependency(Group("com.github.sbt"), Artifact("sbt-native-packager"), Version("1.9.11")) 79 | // // postgres 80 | //// Dependency(Group("org.postgresql"), Artifact("postgresql"), Version("42.2.23")) 81 | // ) 82 | // 83 | // def run = 84 | // ZIO.foreach(tests) { dependency => 85 | // VersionsLive() 86 | // .getVersions(dependency) 87 | // .map(_.mkString(", ")) 88 | // .debug(s"${dependency.group}:${dependency.artifact}") 89 | // } 90 | -------------------------------------------------------------------------------- /src/main/scala/update/services/dependencies/DependencyLoader.scala: -------------------------------------------------------------------------------- 1 | package update.services.dependencies 2 | 3 | import update.model.Dependency 4 | import update.services.* 5 | import zio.* 6 | 7 | object DependencyLoader: 8 | val live: ZLayer[Files, Nothing, DependencyLoader] = 9 | for 10 | sbtLoader <- DependencyLoaderSbtVersion.layer 11 | scalaLoader <- DependencyLoaderScalaSources.layer 12 | yield ZEnvironment(DependencyLoaderCombined(List(sbtLoader.get, scalaLoader.get))) 13 | 14 | def getDependencies(root: String): ZIO[DependencyLoader, Throwable, List[WithSource[Dependency]]] = 15 | ZIO.serviceWithZIO(_.getDependencies(root)) 16 | 17 | trait DependencyLoader: 18 | def getDependencies(root: String): Task[List[WithSource[Dependency]]] 19 | -------------------------------------------------------------------------------- /src/main/scala/update/services/dependencies/DependencyLoaderCombined.scala: -------------------------------------------------------------------------------- 1 | package update.services.dependencies 2 | 3 | import update.model.Dependency 4 | import zio.* 5 | 6 | final case class DependencyLoaderCombined( 7 | loaders: List[DependencyLoader] 8 | ) extends DependencyLoader: 9 | def getDependencies(root: String): Task[List[WithSource[Dependency]]] = 10 | for dependencies <- ZIO.foreachPar(loaders)(_.getDependencies(root)) 11 | yield dependencies.flatten 12 | -------------------------------------------------------------------------------- /src/main/scala/update/services/dependencies/DependencyLoaderSbtVersion.scala: -------------------------------------------------------------------------------- 1 | package update.services.dependencies 2 | 3 | import update.model.* 4 | import zio.* 5 | 6 | final case class DependencyLoaderSbtVersion() extends DependencyLoader: 7 | private val regex = """sbt.version\s*=\s*([\d\.]+)""".r 8 | 9 | // look in the project/build.properties file for the sbt version 10 | def getDependencies(root: String): Task[List[WithSource[Dependency]]] = { 11 | for 12 | buildProperties <- ZIO 13 | .readFile(root + "/project/build.properties") 14 | .option 15 | .some 16 | versionMatch <- ZIO.fromOption(regex.findFirstMatchIn(buildProperties)) 17 | sourceInfo = SourceInfo( 18 | root + "/project/build.properties", 19 | versionMatch.start(1), 20 | versionMatch.end(1) 21 | ) 22 | version = Version(versionMatch.group(1)) 23 | yield WithSource(Dependency(Group("org.scala-sbt"), Artifact("sbt"), version, true), sourceInfo) 24 | }.unsome.map(_.toList) 25 | 26 | object DependencyLoaderSbtVersion: 27 | val layer = ZLayer.succeed(DependencyLoaderSbtVersion()) 28 | -------------------------------------------------------------------------------- /src/main/scala/update/services/dependencies/DependencyLoaderScalaSources.scala: -------------------------------------------------------------------------------- 1 | package update.services.dependencies 2 | 3 | import coursierapi.{Cache, Fetch} 4 | import dotty.tools.dotc.ast.Trees.{Tree, Untyped} 5 | import dotty.tools.dotc.ast.untpd 6 | import dotty.tools.dotc.core.Contexts.Context 7 | import dotty.tools.dotc.util.SourceFile 8 | import dotty.tools.io.AbstractFile 9 | import update.model.Dependency 10 | import update.services.Files 11 | import update.utils.ParsingDriver 12 | import zio.* 13 | import zio.nio.file.Path 14 | 15 | import java.io.File 16 | import scala.collection.mutable.ListBuffer 17 | import scala.io.Codec 18 | import scala.jdk.CollectionConverters.* 19 | 20 | /** This class uses a parser-only instance of the Dotty compiler to parse Scala 21 | * build sources (.sbt files, project/ files, mill's .sc files, etc) and 22 | * extract dependencies from them. 23 | */ 24 | final case class DependencyLoaderScalaSources(files: Files) extends DependencyLoader: 25 | private val fetch = Fetch.create().withCache(Cache.create()) 26 | fetch.addDependencies(coursierapi.Dependency.of("org.scala-lang", "scala3-library_3", "3.4.0")) 27 | private val extraLibraries = fetch.fetch().asScala.map(_.toPath()).toSeq 28 | private val driver = new ParsingDriver( 29 | List("-color:never", "-classpath", extraLibraries.mkString(File.pathSeparator)) 30 | ) 31 | private given ctx: Context = driver.currentCtx 32 | 33 | def getDependencies(root: String): Task[List[WithSource[Dependency]]] = 34 | for paths <- files.allBuildScalaPaths(root) 35 | yield 36 | val allTrees = ListBuffer.empty[untpd.Tree] 37 | paths.foreach { path => 38 | loadPath(path) 39 | val trees: List[Tree[Untyped]] = driver.currentCtx.run.units.map(_.untpdTree) 40 | allTrees ++= trees 41 | } 42 | DependencyTraverser.getDependencies(allTrees.toList) 43 | 44 | private def loadPath(path: Path): Unit = 45 | val file = AbstractFile.getFile(path.toFile.toPath) 46 | var contents = new String(file.toByteArray, Codec.UTF8.charSet) 47 | 48 | // NOTE: Until I figure out how to allow toplevel definitions, 49 | // I'm wrapping the contents in a $$wrapper object. 50 | val pathString = path.toString 51 | if pathString.endsWith(".sbt") || pathString.endsWith(".sc") then contents = s"""object $$wrapper {\n$contents\n}""" 52 | 53 | val sourceFile = SourceFile.virtual(path.toFile.toURI.toString, contents) 54 | val diagnostics = driver.run(path.toFile.toURI, sourceFile) 55 | diagnostics.foreach(println) 56 | 57 | object DependencyLoaderScalaSources: 58 | val layer = ZLayer.fromFunction(DependencyLoaderScalaSources.apply) 59 | -------------------------------------------------------------------------------- /src/main/scala/update/services/dependencies/DependencyTraverser.scala: -------------------------------------------------------------------------------- 1 | package update.services.dependencies 2 | 3 | import dotty.tools.dotc.ast.{Trees, untpd} 4 | import dotty.tools.dotc.ast.untpd.UntypedTreeTraverser 5 | import dotty.tools.dotc.core.Constants.Constant 6 | import dotty.tools.dotc.core.Contexts.Context 7 | import update.* 8 | import update.model.Dependency 9 | import update.services.* 10 | 11 | import scala.collection.mutable 12 | import scala.collection.mutable.ListBuffer 13 | 14 | object DependencyTraverser: 15 | 16 | def getDependencies(trees: List[untpd.Tree])(using Context): List[WithSource[Dependency]] = 17 | traverseValDefs(trees) 18 | traverseDependencies(trees) 19 | dependencies.toList 20 | 21 | // Stores parsed version definitions, e.g.: 22 | // val zioVersion = "version" 23 | private val defs: mutable.Map[String, (String, untpd.Tree)] = mutable.Map.empty 24 | 25 | private val dependencies: ListBuffer[WithSource[Dependency]] = ListBuffer.empty 26 | 27 | private def traverseValDefs(trees: List[untpd.Tree])(using Context): Unit = 28 | val traverser = new UntypedTreeTraverser: 29 | override def traverse(tree: untpd.Tree)(using Context): Unit = 30 | tree match 31 | // Matches: val ident = "version" 32 | case untpd.ValDef(Name(name), _, tree @ StringLiteral(version)) => 33 | defs += (name -> (version, tree)) 34 | 35 | // Matches: def ident = "version" 36 | case untpd.DefDef(Name(name), _, _, tree @ StringLiteral(version)) => 37 | defs += (name -> (version, tree)) 38 | 39 | case _ => 40 | traverseChildren(tree) 41 | trees.foreach(traverser.traverse) 42 | 43 | private def traverseDependencies(trees: List[untpd.Tree])(using Context): Unit = 44 | val traverser = new UntypedTreeTraverser: 45 | override def traverse(tree: untpd.Tree)(using Context): Unit = 46 | tree match 47 | // Matches: scalaVersion := "3.4.0" 48 | case MatchScalaVersion(versionTree @ StringLiteral(version)) => 49 | println(s"scala version = $version") 50 | dependencies += WithSource.fromTree(Dependency.scalaVersion(version), versionTree) 51 | 52 | // Matches: "groupId" %% "artifactId" % "version" 53 | case MatchDependency(group, artifact, versionTree @ StringLiteral(version), isJava) => 54 | dependencies += WithSource.fromTree(Dependency(group, artifact, version, isJava), versionTree) 55 | 56 | // Matches: "groupId" %% "artifactId" % ident 57 | case MatchDependency(group, artifact, SelectOrIdent(ident), isJava) => 58 | defs.get(ident).foreach { (version, tree) => 59 | dependencies += WithSource.fromTree(Dependency(group, artifact, version, isJava), tree) 60 | } 61 | 62 | // ivy"dev.zio::zio:$zioVersion", 63 | case untpd.InterpolatedString( 64 | _, 65 | List( 66 | Trees.Thicket(List(StringLiteral(MillGroupArtifact(group, artifact, colons)), SelectOrIdent(ident))), 67 | _ 68 | ) 69 | ) => 70 | defs.get(ident).foreach { (version, tree) => 71 | dependencies += WithSource.fromTree(Dependency(group, artifact, version, colons == 1), tree) 72 | } 73 | 74 | case untpd.InterpolatedString( 75 | _, 76 | List(tree @ StringLiteral(MillGroupArtifactVersion(group, artifact, version, colons))) 77 | ) => 78 | val withSource = WithSource.fromTree(Dependency(group, artifact, version, colons == 1), tree) 79 | 80 | // We want the position of the version, so we shift it by the length of the group + artifact + colons 81 | // group::artifact:version 82 | // >>>>>>>>>>>>>>>> 83 | dependencies += withSource.shiftStart(group.length + artifact.length + colons).shiftEnd(1) 84 | 85 | case tree => 86 | // if tree.toString.contains("scalaVersion") then println(s"tree = ${tree}") 87 | traverseChildren(tree) 88 | trees.foreach(traverser.traverse) 89 | 90 | //////////////// 91 | // Extractors // 92 | //////////////// 93 | 94 | // Matches: group::artifact or group:artifact and returns the # of colons as well 95 | object MillGroupArtifact: 96 | def unapply(string: String): Option[(String, String, Int)] = 97 | string match 98 | case s"$group::$artifact:" => Some((group, artifact, 2)) 99 | case s"$group:$artifact:" => Some((group, artifact, 1)) 100 | case s"$group:::$artifact:" => Some((group, artifact, 3)) 101 | case _ => None 102 | 103 | object MillGroupArtifactVersion: 104 | def unapply(string: String): Option[(String, String, String, Int)] = 105 | string match 106 | case s"$group::$artifact:$version" => Some((group, artifact, version, 2)) 107 | case s"$group:$artifact:$version" => Some((group, artifact, version, 1)) 108 | case s"$group:::$artifact:$version" => Some((group, artifact, version, 3)) 109 | case _ => None 110 | 111 | // Matches either lhs. or just 112 | object SelectOrIdent: 113 | def unapply(tree: untpd.Tree)(using Context): Option[String] = tree match 114 | case block: Trees.Block[?] if block.stats.isEmpty => SelectOrIdent.unapply(block.expr) 115 | case untpd.Select(_, Name(name)) => Some(name) 116 | case untpd.Ident(Name(name)) => Some(name) 117 | case _ => None 118 | 119 | // match scala version assignment 120 | // matches: scalaVersion := "3.4.0" 121 | object MatchScalaVersion: 122 | def unapply(tree: untpd.Tree)(using Context): Option[untpd.Tree] = 123 | tree match 124 | case untpd.InfixOp(Ident("scalaVersion"), Ident(":="), versionTree) => 125 | Some(versionTree) 126 | case untpd.InfixOp( 127 | untpd.InfixOp(Ident("ThisBuild"), Ident("/"), Ident("scalaVersion")), 128 | Ident(":="), 129 | versionTree 130 | ) => 131 | Some(versionTree) 132 | case _ => 133 | None 134 | 135 | // matcher for: "groupId" %% "artifactId" % tree 136 | object MatchDependency: 137 | def unapply(tree: untpd.Tree)(using Context): Option[(String, String, untpd.Tree, Boolean)] = 138 | tree match 139 | case untpd.InfixOp( 140 | untpd.InfixOp(StringLiteral(group), Ident(percents @ ("%%%" | "%%" | "%")), StringLiteral(artifact)), 141 | Ident("%"), 142 | version 143 | ) => 144 | val isJava = percents == "%" 145 | Some((group, artifact, version, isJava)) 146 | case _ => None 147 | 148 | object Name: 149 | def unapply(name: dotty.tools.dotc.core.Names.Name): Some[String] = 150 | Some(name.toString) 151 | 152 | object Ident: 153 | def unapply(tree: untpd.Tree): Option[String] = tree match 154 | case untpd.Ident(Name(name)) => Some(name) 155 | case _ => None 156 | 157 | object StringLiteral: 158 | def unapply(tree: untpd.Tree): Option[String] = tree match 159 | case untpd.Literal(constant: Constant) => Some(constant.stringValue) 160 | case _ => None 161 | -------------------------------------------------------------------------------- /src/main/scala/update/services/dependencies/WithSource.scala: -------------------------------------------------------------------------------- 1 | package update.services.dependencies 2 | 3 | import dotty.tools.dotc.ast.Trees.Tree 4 | import dotty.tools.dotc.ast.untpd 5 | import dotty.tools.dotc.core.Contexts.Context 6 | 7 | final case class SourceInfo(path: String, start: Int, end: Int): 8 | def shiftStart(by: Int): SourceInfo = copy(start = start + by) 9 | def shiftEnd(by: Int): SourceInfo = copy(end = end + by) 10 | 11 | final case class WithSource[A](value: A, sourceInfo: SourceInfo): 12 | def start: Int = sourceInfo.start 13 | def end: Int = sourceInfo.end 14 | def path: String = sourceInfo.path 15 | 16 | def shiftStart(by: Int): WithSource[A] = copy(sourceInfo = sourceInfo.shiftStart(by)) 17 | def shiftEnd(by: Int): WithSource[A] = copy(sourceInfo = sourceInfo.shiftEnd(by)) 18 | 19 | object WithSource: 20 | def fromTree[A](value: A, tree: untpd.Tree)(using Context): WithSource[A] = 21 | val path = tree.source.path 22 | // If the file allows top-level definitions (e.g., an .sbt or a .sc), then we need to 23 | // account for the addition of the `object $wrapper {\n` that's done in DependencyLoader.scala 24 | val shift = if path.endsWith(".sbt") || path.endsWith(".sc") then 18 else 0 25 | WithSource( 26 | value = value, 27 | sourceInfo = SourceInfo( 28 | path = path.stripPrefix("file:"), 29 | start = tree.sourcePos.span.start - shift + 1, // shift over by a 1 to account for the double quote char 30 | end = tree.sourcePos.span.end - shift - 1 31 | ) 32 | ) 33 | -------------------------------------------------------------------------------- /src/main/scala/update/utils/FileUtils.scala: -------------------------------------------------------------------------------- 1 | package update.utils 2 | 3 | import zio.nio.file.{Files, Path} 4 | import zio.stream.ZStream 5 | 6 | import java.io.IOException 7 | 8 | object FileUtils: 9 | 10 | /** Returns a Stream of all Scala 11 | */ 12 | def allScalaFiles(path: Path): ZStream[Any, IOException, Path] = 13 | allFilesWithExt(path, ".scala") 14 | 15 | /** Returns a Stream of all mill build file (ammonite script) 16 | */ 17 | def allMillFiles(path: Path): ZStream[Any, IOException, Path] = 18 | allFilesWithExt(path, ".sc") 19 | 20 | /** Returns file extension of given path, if it exists 21 | */ 22 | def extension(path: Path): Option[String] = 23 | path.filename.toString().split('.').lastOption 24 | 25 | private def allFilesWithExt(path: Path, extension: String): ZStream[Any, IOException, Path] = 26 | ZStream.whenZIO(Files.isDirectory(path)) { 27 | Files 28 | .newDirectoryStream(path) 29 | .flatMap { path => 30 | for 31 | isDir <- ZStream.fromZIO(Files.isDirectory(path)) 32 | res <- if isDir then allScalaFiles(path) 33 | else ZStream.succeed(path) 34 | yield res 35 | } 36 | .filter(_.toString.endsWith(extension)) 37 | } 38 | -------------------------------------------------------------------------------- /src/main/scala/update/utils/Rewriter.scala: -------------------------------------------------------------------------------- 1 | package update.utils 2 | 3 | object Rewriter: 4 | final case class Patch(start: Int, end: Int, replacement: String): 5 | def length: Int = end - start 6 | 7 | /** This function rewrites a given source string based on a list of patches. 8 | * Each patch contains a start and end index, and a replacement string. The 9 | * function applies these patches in order of their start index. If any 10 | * patches overlap (i.e., a patch starts before the previous one ends), an 11 | * exception is thrown. 12 | */ 13 | def rewrite(source: String, patches: List[Patch]): String = 14 | val sortedPatches = patches.sortBy(_.start) 15 | 16 | val sb = new StringBuilder 17 | var lastEnd = 0 18 | 19 | // Apply each patch in order 20 | sortedPatches.foreach { patch => 21 | // If a patch starts before the last one ends, throw an exception 22 | if patch.start < lastEnd then 23 | throw new IllegalArgumentException( 24 | s"Overlapping patches: $patch and ${sortedPatches.find(_.start < lastEnd).get}" 25 | ) 26 | 27 | // Append the part of the source string before the patch 28 | try sb.append(source.substring(lastEnd, patch.start)) 29 | catch 30 | case e: StringIndexOutOfBoundsException => 31 | throw new IllegalArgumentException( 32 | s"Patch $patch starts at index ${patch.start} but source string has length ${source.length}" 33 | ) 34 | // Append the replacement string 35 | sb.append(patch.replacement) 36 | // Update the end index of the last applied patch 37 | lastEnd = patch.end 38 | } 39 | 40 | // Append the part of the source string after the last patch 41 | sb.append(source.substring(lastEnd)) 42 | sb.toString 43 | -------------------------------------------------------------------------------- /src/main/scala/update/utils/UntypedInteractiveCompiler.scala: -------------------------------------------------------------------------------- 1 | package update.utils 2 | 3 | import dotty.tools.* 4 | import dotty.tools.dotc.ast.{Trees, tpd} 5 | import dotty.tools.dotc.core.* 6 | import dotty.tools.dotc.core.Phases.Phase 7 | import dotty.tools.dotc.interactive.* 8 | import dotty.tools.dotc.parsing.Parser 9 | import dotty.tools.dotc.{CompilationUnit, Compiler, Driver, ast, core, reporting, typer, util} 10 | 11 | import scala.collection.* 12 | import scala.language.unsafeNulls 13 | 14 | class UntypedInteractiveCompiler extends Compiler: 15 | override def phases: List[List[Phase]] = List( 16 | List(new Parser) 17 | ) 18 | 19 | import dotty.tools.dotc.ast.Trees.* 20 | import dotty.tools.dotc.core.Contexts.* 21 | import dotty.tools.dotc.reporting.* 22 | import dotty.tools.dotc.util.* 23 | import dotty.tools.io.AbstractFile 24 | 25 | import java.net.URI 26 | import scala.language.unsafeNulls 27 | 28 | /** A driver which simply parses and produces untyped Trees */ 29 | class ParsingDriver(val settings: List[String]) extends Driver: 30 | import tpd.* 31 | 32 | override def sourcesRequired: Boolean = false 33 | 34 | private val myInitCtx: Context = 35 | val rootCtx = initCtx.fresh.addMode(Mode.ReadPositions).addMode(Mode.Interactive) 36 | rootCtx.setSetting(rootCtx.settings.YretainTrees, true) 37 | rootCtx.setSetting(rootCtx.settings.YcookComments, true) 38 | rootCtx.setSetting(rootCtx.settings.YreadComments, true) 39 | val ctx = setup(settings.toArray, rootCtx) match 40 | case Some((_, ctx)) => ctx 41 | case None => rootCtx 42 | ctx.initialize()(using ctx) 43 | ctx 44 | 45 | private var myCtx: Context = myInitCtx 46 | def currentCtx: Context = myCtx 47 | 48 | private val compiler: Compiler = new UntypedInteractiveCompiler 49 | 50 | private val myOpenedFiles = new mutable.LinkedHashMap[URI, SourceFile]: 51 | override def default(key: URI) = NoSource 52 | 53 | private val myOpenedTrees = new mutable.LinkedHashMap[URI, List[SourceTree]]: 54 | override def default(key: URI) = Nil 55 | 56 | private val myCompilationUnits = new mutable.LinkedHashMap[URI, CompilationUnit] 57 | 58 | initialize() 59 | 60 | def run(uri: URI, sourceCode: String): List[Diagnostic] = run(uri, SourceFile.virtual(uri, sourceCode)) 61 | 62 | def run(uri: URI, source: SourceFile): List[Diagnostic] = 63 | import typer.ImportInfo.* 64 | 65 | val previousCtx = myCtx 66 | try 67 | val reporter = 68 | new StoreReporter(null) with UniqueMessagePositions with HideNonSensicalMessages 69 | 70 | val run = compiler.newRun(using myInitCtx.fresh.setReporter(reporter)) 71 | myCtx = run.runContext.withRootImports 72 | 73 | given Context = myCtx 74 | 75 | myOpenedFiles(uri) = source 76 | 77 | run.compileSources(List(source)) 78 | run.printSummary() 79 | val ctxRun = ctx.run.nn 80 | val unit = if ctxRun.units.nonEmpty then ctxRun.units.head else ctxRun.suspendedUnits.head 81 | val t = unit.tpdTree 82 | myOpenedTrees(uri) = topLevelTrees(t, source) 83 | myCompilationUnits(uri) = unit 84 | myCtx = myCtx.fresh.setPhase(myInitCtx.base.typerPhase) 85 | 86 | reporter.removeBufferedMessages 87 | catch 88 | case _: FatalError => 89 | myCtx = previousCtx 90 | close(uri) 91 | Nil 92 | 93 | def close(uri: URI): Unit = 94 | myOpenedFiles.remove(uri) 95 | myOpenedTrees.remove(uri) 96 | myCompilationUnits.remove(uri) 97 | 98 | private def topLevelTrees(topTree: Tree, source: SourceFile): List[SourceTree] = 99 | val trees = new mutable.ListBuffer[SourceTree] 100 | 101 | def addTrees(tree: Tree): Unit = tree match 102 | case PackageDef(_, stats) => 103 | stats.foreach(addTrees) 104 | case imp: Import => 105 | trees += SourceTree(imp, source) 106 | case tree: TypeDef => 107 | trees += SourceTree(tree, source) 108 | case _ => 109 | addTrees(topTree) 110 | 111 | trees.toList 112 | 113 | /** Initialize this driver and compiler. 114 | * 115 | * This is necessary because an `InteractiveDriver` can be put to work 116 | * without having compiled anything (for instance, resolving a symbol coming 117 | * from a different compiler in this compiler). In those cases, an 118 | * un-initialized compiler may crash (for instance if late-compilation is 119 | * needed). 120 | */ 121 | private def initialize(): Unit = 122 | val run = compiler.newRun(using myInitCtx.fresh) 123 | myCtx = run.runContext 124 | run.compileUnits(Nil, myCtx) 125 | -------------------------------------------------------------------------------- /src/test/scala/update/DependencyLoaderSpec.scala: -------------------------------------------------------------------------------- 1 | package update 2 | 3 | import update.model.Dependency 4 | import update.services.Files 5 | import update.services.dependencies.DependencyLoader 6 | import update.test.utils.TestFileHelper 7 | import zio.* 8 | import zio.nio.file 9 | import zio.test.* 10 | 11 | object DependencyLoaderSpec extends ZIOSpecDefault: 12 | 13 | val buildSbtString = """ 14 | val zioVersion = "1.0.12" 15 | 16 | libraryDependencies ++= Seq( 17 | "dev.zio" %% "zio" % zioVersion, 18 | "dev.zio" %% "zio-json" % "0.2.0", 19 | "io.getquill" %% "quill-jdbc-zio" % Dependencies.quill, 20 | "dev.cheleb" %% "zio-pravega" % "0.1.0-RC12", 21 | "org.postgresql" % "postgresql" % "42.2.5" 22 | ) 23 | """ 24 | 25 | val dependenciesString = """ 26 | object Dependencies { 27 | val quill = "3.10.0" 28 | } 29 | """ 30 | 31 | def spec = 32 | suiteAll("DependencyUpdate") { 33 | test("should update the version of a dependency") { 34 | for 35 | dir <- TestFileHelper.createTempFiles( 36 | "build.sbt" -> buildSbtString, 37 | "project/Dependencies.scala" -> dependenciesString 38 | ) 39 | dependencies <- DependencyLoader.getDependencies(dir.toString) 40 | yield assertTrue( 41 | dependencies.map(_.value).toSet == 42 | Set( 43 | Dependency("dev.zio", "zio", "1.0.12", false), 44 | Dependency("dev.zio", "zio-json", "0.2.0", false), 45 | Dependency("io.getquill", "quill-jdbc-zio", "3.10.0", false), 46 | Dependency("dev.cheleb", "zio-pravega", "0.1.0-RC12", false), 47 | Dependency("org.postgresql", "postgresql", "42.2.5", true) 48 | ) 49 | ) 50 | } 51 | }.provide( 52 | Scope.default, 53 | DependencyLoader.live, 54 | Files.live 55 | ) 56 | -------------------------------------------------------------------------------- /src/test/scala/update/RewriterSpec.scala: -------------------------------------------------------------------------------- 1 | package update 2 | 3 | import update.utils.Rewriter 4 | import update.utils.Rewriter.Patch 5 | import zio.test.* 6 | 7 | object RewriterSpec extends ZIOSpecDefault: 8 | def spec = 9 | suiteAll("Rewriter") { 10 | test("rewrite with a single patch") { 11 | val source = "Hello World" 12 | val patches = List(Patch(6, 11, "Scala")) 13 | val result = Rewriter.rewrite(source, patches) 14 | assertTrue(result == "Hello Scala") 15 | } 16 | 17 | test("rewrite with multiple non-overlapping patches") { 18 | val source = "Hello World" 19 | val patches = List(Patch(0, 5, "Hi"), Patch(6, 11, "Scala")) 20 | val result = Rewriter.rewrite(source, patches) 21 | assertTrue(result == "Hi Scala") 22 | } 23 | 24 | // this should throw an error 25 | test("rewrite with overlapping patches") { 26 | val source = "Hello World" 27 | val patches = List(Patch(0, 11, "Bonjour"), Patch(6, 11, "Scala")) 28 | 29 | try 30 | val result = Rewriter.rewrite(source, patches) 31 | assertTrue(false) 32 | catch 33 | case e: Exception => 34 | assertTrue(e.getMessage.contains("Overlapping patches")) 35 | } 36 | 37 | test("rewrite with patches at the beginning and end of source") { 38 | val source = "Hello World" 39 | val patches = List(Patch(0, 1, "J"), Patch(10, 11, "d!")) 40 | val result = Rewriter.rewrite(source, patches) 41 | assertTrue(result == "Jello World!") 42 | } 43 | 44 | test("rewrite with empty replacement") { 45 | val source = "Hello World" 46 | val patches = List(Patch(5, 6, "")) 47 | val result = Rewriter.rewrite(source, patches) 48 | assertTrue(result == "HelloWorld") 49 | } 50 | 51 | test("rewrite with no patches") { 52 | val source = "Hello World" 53 | val patches = List() 54 | val result = Rewriter.rewrite(source, patches) 55 | assertTrue(result == "Hello World") 56 | } 57 | 58 | test("rewrite with a patch that replaces the entire source") { 59 | val source = "Hello World" 60 | val patches = List(Patch(0, source.length, "Goodbye")) 61 | val result = Rewriter.rewrite(source, patches) 62 | assertTrue(result == "Goodbye") 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/test/scala/update/model/VersionSpec.scala: -------------------------------------------------------------------------------- 1 | package update.model 2 | 3 | import zio.test.* 4 | 5 | object VersionSpec extends ZIOSpecDefault: 6 | private val parsingExpectations = 7 | List( 8 | "1.0.0" -> Version.SemVer(1, 0, 0, None), 9 | "1.0.0-RC4" -> Version.SemVer(1, 0, 0, Some(PreRelease.RC(4))), 10 | "2.0-RC4" -> Version.SemVer(2, 0, 0, Some(PreRelease.RC(4))), 11 | "1.5.5-M1" -> Version.SemVer(1, 5, 5, Some(PreRelease.M(1))), 12 | "4.5.5.5" -> Version.Other("4.5.5.5"), 13 | "i-hate-semver" -> Version.Other("i-hate-semver"), 14 | "1.1.1-alpha" -> Version.SemVer(1, 1, 1, Some(PreRelease.Alpha(None))), 15 | "1.1.1-alpha.5" -> Version.SemVer(1, 1, 1, Some(PreRelease.Alpha(Some(5)))), 16 | "1.1.1-beta" -> Version.SemVer(1, 1, 1, Some(PreRelease.Beta(None))), 17 | "1.1.1-beta.5" -> Version.SemVer(1, 1, 1, Some(PreRelease.Beta(Some(5)))) 18 | ) 19 | 20 | private val orderingExpectations = 21 | List( 22 | List("1.1.1", "2.0.0", "0.0.5") -> List("0.0.5", "1.1.1", "2.0.0"), 23 | List( 24 | "0.5.0", 25 | "0.1.0-RC12", 26 | "0.1.0-RC13", 27 | "1.0.0", 28 | "1.0.0-RC2", 29 | "1.0.0-RC1", 30 | "1.0.0-M1", 31 | "1.0.0-alpha", 32 | "1.0.0-alpha.1", 33 | "2.0.0", 34 | "2.1.0", 35 | "2.0.1" 36 | ) -> List( 37 | "0.1.0-RC12", 38 | "0.1.0-RC13", 39 | "0.5.0", 40 | "1.0.0-alpha", 41 | "1.0.0-alpha.1", 42 | "1.0.0-M1", 43 | "1.0.0-RC1", 44 | "1.0.0-RC2", 45 | "1.0.0", 46 | "2.0.0", 47 | "2.0.1", 48 | "2.1.0" 49 | ) 50 | ).map { (input, expected) => 51 | input.map(Version(_)) -> expected.map(Version(_)) 52 | } 53 | 54 | def spec = 55 | suiteAll("Version") { 56 | 57 | suite("Parsing")( 58 | parsingExpectations.map { (input, expected) => 59 | test(s"parse $input") { 60 | assertTrue(Version(input) == expected) 61 | } 62 | }* 63 | ) 64 | 65 | suite("Ordering")( 66 | orderingExpectations.map { (input, expected) => 67 | test(s"order $input") { 68 | assertTrue(input.sorted == expected) 69 | } 70 | }* 71 | ) 72 | 73 | } 74 | -------------------------------------------------------------------------------- /src/test/scala/update/services/ScalaUpdateSpec.scala: -------------------------------------------------------------------------------- 1 | package update.services 2 | 3 | import update.model.* 4 | import update.services.dependencies.DependencyLoader 5 | import update.test.utils.TestFileHelper 6 | import zio.* 7 | import zio.test.* 8 | 9 | object ScalaUpdateSpec extends ZIOSpecDefault: 10 | 11 | ////////////////////////// 12 | // Original Build Files // 13 | ////////////////////////// 14 | 15 | val buildSbtString = """ 16 | val zioVersion = "1.0.12" 17 | 18 | libraryDependencies ++= Seq( 19 | "dev.zio" %% "zio" % zioVersion, 20 | "dev.zio" %% "zio-json" % "0.2.0", 21 | "io.getquill" %% "quill-jdbc-zio" % Dependencies.quill, 22 | "dev.cheleb" %% "zio-pravega" % "0.1.0-RC12", 23 | "org.postgresql" % "postgresql" % Dependencies.postgresVersion 24 | ) 25 | """ 26 | 27 | val expectedBuildSbtString = 28 | """ 29 | val zioVersion = "2.0.0" 30 | 31 | libraryDependencies ++= Seq( 32 | "dev.zio" %% "zio" % zioVersion, 33 | "dev.zio" %% "zio-json" % "2.2.2", 34 | "io.getquill" %% "quill-jdbc-zio" % Dependencies.quill, 35 | "dev.cheleb" %% "zio-pravega" % "0.1.0-RC12", 36 | "org.postgresql" % "postgresql" % Dependencies.postgresVersion 37 | ) 38 | """ 39 | 40 | val buildMillString = """ 41 | import $file.Dependencies, Dependencies.Dependencies 42 | 43 | object foo extends ScalaModule { 44 | def scalaVersion = "2.13.8" 45 | val zioJsonVersion = "1.2.2" 46 | 47 | def ivyDeps = Agg( 48 | ivy"dev.zio::zio-json:$zioJsonVersion", 49 | ivy"io.getquill::quill-jdbc-zio:${Dependencies.quill}", 50 | ivy"dev.cheleb:zio-pravega:0.1.0-RC12", 51 | ivy"org.postgresql:postgresql:1.2.3", 52 | ) 53 | } 54 | """ 55 | 56 | val expectedMillString = """ 57 | import $file.Dependencies, Dependencies.Dependencies 58 | 59 | object foo extends ScalaModule { 60 | def scalaVersion = "2.13.8" 61 | val zioJsonVersion = "2.2.2" 62 | 63 | def ivyDeps = Agg( 64 | ivy"dev.zio::zio-json:$zioJsonVersion", 65 | ivy"io.getquill::quill-jdbc-zio:${Dependencies.quill}", 66 | ivy"dev.cheleb:zio-pravega:0.1.0-RC12", 67 | ivy"org.postgresql:postgresql:42.2.6", 68 | ) 69 | } 70 | """ 71 | 72 | val dependenciesString = 73 | """ 74 | object Dependencies { 75 | val quill = "3.10.0" 76 | def postgresVersion = "42.2.0" 77 | } 78 | """ 79 | 80 | val expectedDependenciesString = 81 | """ 82 | object Dependencies { 83 | val quill = "3.11.0" 84 | def postgresVersion = "42.2.6" 85 | } 86 | """ 87 | 88 | val buildPropertiesString = """ 89 | sbt.version=1.5.5 90 | """ 91 | 92 | val pluginsSbtString = """ 93 | addSbtPlugin("org.scalafmt" % "sbt-scalafmt" % "2.4.2") 94 | addSbtPlugin("org.scalameta" % "sbt-scalafix" % "0.9.31") 95 | """ 96 | 97 | ///////////////////// 98 | // Latest Versions // 99 | ///////////////////// 100 | 101 | val stubVersions = Map( 102 | (Group("dev.zio"), Artifact("zio")) -> List(Version("1.0.13"), Version("2.0.0")), 103 | (Group("dev.zio"), Artifact("zio-json")) -> List(Version("2.2.2")), 104 | (Group("io.getquill"), Artifact("quill-jdbc-zio")) -> List(Version("3.11.0")), 105 | (Group("dev.cheleb"), Artifact("zio-pravega")) -> List(Version("0.1.0-RC13")), 106 | (Group("org.postgresql"), Artifact("postgresql")) -> List(Version("42.2.6")), 107 | // plugins 108 | (Group("org.scalafmt"), Artifact("sbt-scalafmt")) -> List(Version("2.5.0")), 109 | (Group("org.scalameta"), Artifact("sbt-scalafix")) -> List(Version("0.9.32")), 110 | // build properties 111 | (Group("org.scala-sbt"), Artifact("sbt")) -> List(Version("1.9.9")) 112 | ) 113 | 114 | ////////////////////// 115 | // Expected Results // 116 | ////////////////////// 117 | 118 | val expectedBuildPropertiesString = """ 119 | sbt.version=1.9.9 120 | """ 121 | 122 | val expectedPluginsSbtString = """ 123 | addSbtPlugin("org.scalafmt" % "sbt-scalafmt" % "2.5.0") 124 | addSbtPlugin("org.scalameta" % "sbt-scalafix" % "0.9.32") 125 | """ 126 | 127 | def spec = 128 | suiteAll("ScalaUpdate") { 129 | test("updates to the latest version of the dependencies") { 130 | for 131 | root <- TestFileHelper.createTempFiles( 132 | "build.sbt" -> buildSbtString, 133 | "project/Dependencies.scala" -> dependenciesString, 134 | "project/plugins.sbt" -> pluginsSbtString, 135 | "project/build.properties" -> buildPropertiesString, 136 | "build.sc" -> buildMillString 137 | ) 138 | _ <- ScalaUpdate.updateAllDependencies(root.toString) 139 | 140 | newBuildSbt <- ZIO.readFile((root / "build.sbt").toString) 141 | newDependencies <- ZIO.readFile((root / "project" / "Dependencies.scala").toString) 142 | newPluginsSbt <- ZIO.readFile((root / "project" / "plugins.sbt").toString) 143 | newBuildProperties <- ZIO.readFile((root / "project" / "build.properties").toString) 144 | newBuildMill <- ZIO.readFile((root / "build.sc").toString) 145 | yield assertTrue( 146 | newBuildSbt == expectedBuildSbtString, 147 | newDependencies == expectedDependenciesString, 148 | newPluginsSbt == expectedPluginsSbtString, 149 | newBuildProperties == expectedBuildPropertiesString, 150 | newBuildMill == expectedMillString 151 | ) 152 | } 153 | }.provide( 154 | Scope.default, 155 | DependencyLoader.live, 156 | Files.live, 157 | ScalaUpdate.layer, 158 | VersionsInMemory.layer(stubVersions) 159 | ) 160 | -------------------------------------------------------------------------------- /src/test/scala/update/test/utils/TestFileHelper.scala: -------------------------------------------------------------------------------- 1 | package update.test.utils 2 | 3 | import zio.nio.file 4 | import zio.nio.file.Path 5 | import zio.* 6 | 7 | import java.io.IOException 8 | 9 | object TestFileHelper: 10 | private def createTempDir: ZIO[Scope, IOException, Path] = 11 | for tempDir <- file.Files.createTempDirectoryScoped(Some("dependency-updater-spec"), Iterable.empty) 12 | yield tempDir 13 | 14 | private def createFile(path: Path, content: String): IO[IOException, Unit] = 15 | ZIO.foreach(path.parent)(file.Files.createDirectories(_)) *> 16 | ZIO.writeFile(path.toString, content) 17 | 18 | def createTempFiles(files: (String, String)*): ZIO[Scope, IOException, Path] = 19 | for 20 | tempDir <- createTempDir 21 | _ <- ZIO.foreachDiscard(files) { case (path, content) => 22 | val filePath = tempDir / path 23 | createFile(filePath, content) 24 | } 25 | yield tempDir 26 | --------------------------------------------------------------------------------