├── .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 |
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 |
66 |
67 | ### Grouped Depenendcies
68 |
69 | If multiple dependencies share a single version, they will be grouped.
70 |
71 |
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 |
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 |
--------------------------------------------------------------------------------