Skip to content

Commit 5d0da51

Browse files
authored
Add dependency update command (#1055)
* Actionable diagnotics * Add dependency update command
1 parent de5282e commit 5d0da51

File tree

19 files changed

+513
-4
lines changed

19 files changed

+513
-4
lines changed

modules/build/src/main/scala/scala/build/PersistentDiagnosticLogger.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class PersistentDiagnosticLogger(parent: Logger) extends Logger {
2626
}
2727

2828
def log(ex: BuildException): Unit = parent.log(ex)
29+
def debug(ex: BuildException): Unit = parent.debug(ex)
2930
def exit(ex: BuildException): Nothing = parent.exit(ex)
3031

3132
def coursierLogger(printBefore: String): coursier.cache.CacheLogger =

modules/build/src/main/scala/scala/build/preprocessing/ScalaPreprocessor.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,8 +247,8 @@ case object ScalaPreprocessor extends Preprocessor {
247247
val toFilePos = Position.Raw.filePos(path, content)
248248
val deps = value {
249249
dependencyTrees
250-
.map { t =>
251-
val pos = toFilePos(Position.Raw(t.start, t.end))
250+
.map { t => /// skip ivy ($ivy.`) or dep syntax ($dep.`)
251+
val pos = toFilePos(Position.Raw(t.start + "$ivy.`".length, t.end))
252252
val strDep = t.prefix.drop(1).mkString(".")
253253
val maybeDep = parseDependency(strDep, pos)
254254
maybeDep.map(dep => Positioned(Seq(pos), dep))
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package scala.build.tests
2+
3+
import com.eed3si9n.expecty.Expecty.expect
4+
import scala.build.options.{BuildOptions, InternalOptions}
5+
import scala.build.Ops._
6+
import scala.build.{BuildThreads, Directories, LocalRepo}
7+
import scala.build.actionable.ActionablePreprocessor
8+
import scala.build.actionable.ActionableDiagnostic._
9+
import coursier.core.Version
10+
11+
class ActionableDiagnosticTests extends munit.FunSuite {
12+
13+
val extraRepoTmpDir = os.temp.dir(prefix = "scala-cli-tests-actionable-diagnostic-")
14+
val directories = Directories.under(extraRepoTmpDir)
15+
val baseOptions = BuildOptions(
16+
internal = InternalOptions(
17+
localRepository = LocalRepo.localRepo(directories.localRepoDir)
18+
)
19+
)
20+
val buildThreads = BuildThreads.create()
21+
22+
test("update os-lib") {
23+
val dependencyOsLib = "com.lihaoyi::os-lib:0.7.8"
24+
val testInputs = TestInputs(
25+
os.rel / "Foo.scala" ->
26+
s"""//> using lib "$dependencyOsLib"
27+
|
28+
|object Hello extends App {
29+
| println("Hello")
30+
|}
31+
|""".stripMargin
32+
)
33+
testInputs.withBuild(baseOptions, buildThreads, None) {
34+
(_, _, maybeBuild) =>
35+
val build = maybeBuild.orThrow
36+
val updateDiagnostics =
37+
ActionablePreprocessor.generateActionableDiagnostics(build.options).orThrow
38+
39+
val osLibDiagnosticOpt = updateDiagnostics.collectFirst {
40+
case diagnostic: ActionableDependencyUpdateDiagnostic => diagnostic
41+
}
42+
43+
expect(osLibDiagnosticOpt.nonEmpty)
44+
val osLibDiagnostic = osLibDiagnosticOpt.get
45+
46+
expect(Version(osLibDiagnostic.newVersion) > Version(osLibDiagnostic.oldDependency.version))
47+
}
48+
}
49+
50+
test("update ivy dependence upickle") {
51+
val dependencyOsLib = "com.lihaoyi::upickle:1.4.0"
52+
val testInputs = TestInputs(
53+
os.rel / "Foo.scala" ->
54+
s"""import $$ivy.`$dependencyOsLib`
55+
|
56+
|object Hello extends App {
57+
| println("Hello")
58+
|}
59+
|""".stripMargin
60+
)
61+
testInputs.withBuild(baseOptions, buildThreads, None) {
62+
(_, _, maybeBuild) =>
63+
val build = maybeBuild.orThrow
64+
val updateDiagnostics =
65+
ActionablePreprocessor.generateActionableDiagnostics(build.options).orThrow
66+
67+
val osLibDiagnosticOpt = updateDiagnostics.collectFirst {
68+
case diagnostic: ActionableDependencyUpdateDiagnostic => diagnostic
69+
}
70+
71+
expect(osLibDiagnosticOpt.nonEmpty)
72+
val osLibDiagnostic = osLibDiagnosticOpt.get
73+
74+
expect(osLibDiagnostic.oldDependency.render == dependencyOsLib)
75+
expect(Version(osLibDiagnostic.newVersion) > Version(osLibDiagnostic.oldDependency.version))
76+
}
77+
}
78+
79+
test("update dep dependence upickle") {
80+
val dependencyOsLib = "com.lihaoyi::upickle:1.4.0"
81+
val testInputs = TestInputs(
82+
os.rel / "Foo.scala" ->
83+
s"""import $$dep.`$dependencyOsLib`
84+
|
85+
|object Hello extends App {
86+
| println("Hello")
87+
|}
88+
|""".stripMargin
89+
)
90+
testInputs.withBuild(baseOptions, buildThreads, None) {
91+
(_, _, maybeBuild) =>
92+
val build = maybeBuild.orThrow
93+
val updateDiagnostics =
94+
ActionablePreprocessor.generateActionableDiagnostics(build.options).orThrow
95+
96+
val osLibDiagnosticOpt = updateDiagnostics.collectFirst {
97+
case diagnostic: ActionableDependencyUpdateDiagnostic => diagnostic
98+
}
99+
100+
expect(osLibDiagnosticOpt.nonEmpty)
101+
val osLibDiagnostic = osLibDiagnosticOpt.get
102+
103+
expect(osLibDiagnostic.oldDependency.render == dependencyOsLib)
104+
expect(Version(osLibDiagnostic.newVersion) > Version(osLibDiagnostic.oldDependency.version))
105+
}
106+
}
107+
108+
}

modules/build/src/test/scala/scala/build/tests/BuildProjectTests.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ class BuildProjectTests extends munit.FunSuite {
4040
this.diagnostics = this.diagnostics ++ diagnostics
4141
}
4242

43-
override def log(ex: BuildException): Unit = {}
43+
override def log(ex: BuildException): Unit = {}
44+
override def debug(ex: BuildException): Unit = {}
4445

4546
override def exit(ex: BuildException): Nothing = ???
4647

modules/build/src/test/scala/scala/build/tests/TestLogger.scala

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ case class TestLogger(info: Boolean = true, debug: Boolean = false) extends Logg
3737

3838
def log(ex: BuildException): Unit =
3939
System.err.println(ex.getMessage)
40+
def debug(ex: BuildException): Unit =
41+
debug(ex.getMessage)
4042
def exit(ex: BuildException): Nothing =
4143
throw new Exception(ex)
4244

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package scala.cli.commands
2+
3+
import caseapp._
4+
import caseapp.core.help.Help
5+
6+
// format: off
7+
@HelpMessage("Update dependencies in project")
8+
final case class DependencyUpdateOptions(
9+
@Recurse
10+
shared: SharedOptions = SharedOptions(),
11+
@Group("DependencyUpdate")
12+
@HelpMessage("Update all dependency")
13+
all: Boolean = false,
14+
)
15+
// format: on
16+
17+
object DependencyUpdateOptions {
18+
implicit lazy val parser: Parser[DependencyUpdateOptions] = Parser.derive
19+
implicit lazy val help: Help[DependencyUpdateOptions] = Help.derive
20+
}

modules/cli/src/main/scala/scala/cli/ScalaCliCommands.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class ScalaCliCommands(
3737
Compile,
3838
Config,
3939
DefaultFile,
40+
DependencyUpdate,
4041
Directories,
4142
Doc,
4243
Doctor,
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package scala.cli.commands
2+
3+
import caseapp._
4+
import os.Path
5+
6+
import scala.build.actionable.ActionableDependencyHandler
7+
import scala.build.actionable.ActionableDiagnostic.ActionableDependencyUpdateDiagnostic
8+
import scala.build.internal.CustomCodeWrapper
9+
import scala.build.options.Scope
10+
import scala.build.{CrossSources, Logger, Position, Sources}
11+
import scala.cli.CurrentParams
12+
import scala.cli.commands.util.SharedOptionsUtil._
13+
14+
object DependencyUpdate extends ScalaCommand[DependencyUpdateOptions] {
15+
override def group = "Main"
16+
override def sharedOptions(options: DependencyUpdateOptions) = Some(options.shared)
17+
18+
def run(options: DependencyUpdateOptions, args: RemainingArgs): Unit = {
19+
val verbosity = options.shared.logging.verbosity
20+
CurrentParams.verbosity = verbosity
21+
22+
val inputs = options.shared.inputsOrExit(args)
23+
val logger = options.shared.logger
24+
val buildOptions = options.shared.buildOptions()
25+
26+
val (crossSources, _) =
27+
CrossSources.forInputs(
28+
inputs,
29+
Sources.defaultPreprocessors(
30+
buildOptions.scriptOptions.codeWrapper.getOrElse(CustomCodeWrapper),
31+
buildOptions.archiveCache,
32+
buildOptions.internal.javaClassNameVersionOpt
33+
),
34+
logger
35+
).orExit(logger)
36+
37+
val scopedSources = crossSources.scopedSources(buildOptions).orExit(logger)
38+
39+
def generateActionableUpdateDiagnostic(scope: Scope)
40+
: Seq[ActionableDependencyUpdateDiagnostic] = {
41+
val sources = scopedSources.sources(scope, crossSources.sharedOptions(buildOptions))
42+
43+
if (verbosity >= 3)
44+
pprint.err.log(sources)
45+
46+
val options = buildOptions.orElse(sources.buildOptions)
47+
ActionableDependencyHandler.createActionableDiagnostics(options).orExit(logger)
48+
}
49+
50+
val actionableMainUpdateDiagnostics = generateActionableUpdateDiagnostic(Scope.Main)
51+
val actionableTestUpdateDiagnostics = generateActionableUpdateDiagnostic(Scope.Test)
52+
val actionableUpdateDiagnostics =
53+
(actionableMainUpdateDiagnostics ++ actionableTestUpdateDiagnostics).distinct
54+
55+
if (options.all)
56+
updateDependencies(actionableUpdateDiagnostics, logger)
57+
else {
58+
println("Updates")
59+
actionableUpdateDiagnostics.foreach(update =>
60+
println(s" * ${update.oldDependency.render} -> ${update.newVersion}")
61+
)
62+
println("""|To update all dependencies run:
63+
| scala-cli dependency-update --all""".stripMargin)
64+
}
65+
}
66+
67+
private def updateDependencies(
68+
actionableUpdateDiagnostics: Seq[ActionableDependencyUpdateDiagnostic],
69+
logger: Logger
70+
): Unit = {
71+
val groupedByFileDiagnostics =
72+
actionableUpdateDiagnostics.flatMap {
73+
diagnostic =>
74+
diagnostic.positions.collect {
75+
case file: Position.File =>
76+
file.path -> (file, diagnostic)
77+
}
78+
}.groupMap(_._1)(_._2)
79+
80+
groupedByFileDiagnostics.foreach {
81+
case (Right(file), diagnostics) =>
82+
val sortedByLine = diagnostics.sortBy(_._1.startPos._1).reverse
83+
val appliedDiagnostics = updateDependencies(file, sortedByLine)
84+
os.write.over(file, appliedDiagnostics)
85+
diagnostics.foreach(diagnostic =>
86+
logger.message(s"Updated dependency to: ${diagnostic._2.to}")
87+
)
88+
case (Left(file), diagnostics) =>
89+
diagnostics.foreach {
90+
diagnostic =>
91+
logger.message(s"Warning: Scala CLI can't update ${diagnostic._2.to} in $file")
92+
}
93+
}
94+
}
95+
96+
private def updateDependencies(
97+
file: Path,
98+
diagnostics: Seq[(Position.File, ActionableDependencyUpdateDiagnostic)]
99+
): String = {
100+
val fileContent = os.read(file)
101+
val startIndicies = Position.Raw.lineStartIndices(fileContent)
102+
103+
diagnostics.foldLeft(fileContent) {
104+
case (fileContent, (file, diagnostic)) =>
105+
val (line, column) = (file.startPos._1, file.startPos._2)
106+
val startIndex = startIndicies(line) + column
107+
val endIndex = startIndex + diagnostic.oldDependency.render.length()
108+
109+
val newDependency = diagnostic.to
110+
s"${fileContent.slice(0, startIndex)}$newDependency${fileContent.drop(endIndex)}"
111+
}
112+
}
113+
114+
}

modules/cli/src/main/scala/scala/cli/internal/CliLogger.scala

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@ class CliLogger(
119119
if (verbosity >= 0)
120120
printEx(ex, new mutable.HashMap)
121121

122+
def debug(ex: BuildException): Unit =
123+
if (verbosity >= 2)
124+
printEx(ex, new mutable.HashMap)
122125
def exit(ex: BuildException): Nothing =
123126
if (verbosity < 0)
124127
sys.exit(1)

modules/core/src/main/scala/scala/build/Logger.scala

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ trait Logger {
2525
): Unit = log(Seq(Diagnostic(message, severity, positions)))
2626

2727
def log(ex: BuildException): Unit
28+
def debug(ex: BuildException): Unit
2829
def exit(ex: BuildException): Nothing
2930

3031
def coursierLogger(printBefore: String): coursier.cache.CacheLogger
@@ -48,6 +49,7 @@ object Logger {
4849

4950
def log(diagnostics: Seq[Diagnostic]): Unit = ()
5051
def log(ex: BuildException): Unit = ()
52+
def debug(ex: BuildException): Unit = ()
5153
def exit(ex: BuildException): Nothing =
5254
throw new Exception(ex)
5355

modules/core/src/main/scala/scala/build/Position.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ object Position {
4444

4545
// from https://github.com/com-lihaoyi/Ammonite/blob/76673f7f3eb9d9ae054482635f57a31527d248de/amm/interp/src/main/scala/ammonite/interp/script/PositionOffsetConversion.scala#L7-L69
4646

47-
private def lineStartIndices(content: String): Array[Int] = {
47+
def lineStartIndices(content: String): Array[Int] = {
4848

4949
val content0 = content.toCharArray
5050

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package scala.cli.integration
2+
3+
import com.eed3si9n.expecty.Expecty.expect
4+
5+
class DependencyUpdateTests extends munit.FunSuite {
6+
7+
test("dependency update test") {
8+
val fileName = "Hello.scala"
9+
val message = "Hello World"
10+
val fileContent =
11+
s"""|//> using lib "com.lihaoyi::os-lib:0.7.8"
12+
|//> using lib "com.lihaoyi::utest:0.7.10"
13+
|import $$ivy.`com.lihaoyi::geny:0.6.5`
14+
|import $$dep.`com.lihaoyi::pprint:0.6.6`
15+
|
16+
|object Hello extends App {
17+
| println("$message")
18+
|}""".stripMargin
19+
val inputs = TestInputs(
20+
Seq(
21+
os.rel / fileName -> fileContent
22+
)
23+
)
24+
inputs.fromRoot { root =>
25+
// update dependencies
26+
val p = os.proc(TestUtil.cli, "dependency-update", "--all", fileName)
27+
.call(
28+
cwd = root,
29+
stdin = os.Inherit,
30+
mergeErrIntoOut = true
31+
)
32+
expect(p.out.text().trim.contains("Updated dependency to"))
33+
expect( // check if dependency update command modify file
34+
os.read(root / fileName) != fileContent)
35+
36+
// after updating dependencies app should run
37+
val out = os.proc(TestUtil.cli, fileName).call(cwd = root).out.text().trim
38+
expect(out == message)
39+
}
40+
}
41+
}

0 commit comments

Comments
 (0)