Skip to content

Commit 532f2a7

Browse files
lihaoyibishabosha
andauthored
Support :dep ... to add library dependenceies in the Scala REPL, add helpful message to //> using dep "..." (#24131)
This PR is a proof-of-concept implementation porting over the Ammonite `import $ivy` functionality to the Scala REPL, using a `:dep` syntax, with an error message on the Scala-CLI `using dep` syntax pointing at `:dep`. Once this lands, along with other related PRs (#24127, #23849) we can probably deprecate Ammonite and refer people to the main Scala REPL since it'll have acquired all the important features Tested manually: <img width="976" height="531" alt="Screenshot 2025-11-11 at 5 34 32 PM" src="https://github.com/user-attachments/assets/740ac57a-c244-46cc-bad0-f27aa1da5716" /> Both `io.get-coursier:interface` and `org.virtuslab:using_directives` are zero-dependency Java libraries, and so can be used directly without worrying about binary compatibility. Both libraries are widely used in the Scala ecosystem (SBT, Mill, ScalaCLI, Bazel, ...) and should be pretty trustworthy and reliable --------- Co-authored-by: Jamie Thompson <[email protected]>
1 parent 8e38254 commit 532f2a7

File tree

6 files changed

+160
-3
lines changed

6 files changed

+160
-3
lines changed

project/Build.scala

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1835,6 +1835,8 @@ object Build {
18351835
"com.lihaoyi" %% "fansi" % "0.5.1",
18361836
"com.lihaoyi" %% "sourcecode" % "0.4.4",
18371837
"com.github.sbt" % "junit-interface" % "0.13.3" % Test,
1838+
"io.get-coursier" % "interface" % "1.0.28", // used by the REPL for dependency resolution
1839+
"org.virtuslab" % "using_directives" % "1.1.4", // used by the REPL for parsing magic comments
18381840
),
18391841
// Configure to use the non-bootstrapped compiler
18401842
scalaInstance := {

repl/src/dotty/tools/repl/AbstractFileClassLoader.scala

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,18 @@ class AbstractFileClassLoader(val root: AbstractFile, parent: ClassLoader, inter
6868
val loaded = findLoadedClass(name) // Check if already loaded
6969
if loaded != null then return loaded
7070

71-
name match { // Don't instrument JDK classes or StopRepl
71+
name match {
72+
// Don't instrument JDK classes or StopRepl. These are often restricted to load from a single classloader
73+
// due to the JDK module system, and so instrumenting them and loading the modified copy of the class
74+
// results in runtime exceptions
7275
case s"java.$_" => super.loadClass(name)
7376
case s"javax.$_" => super.loadClass(name)
7477
case s"sun.$_" => super.loadClass(name)
7578
case s"jdk.$_" => super.loadClass(name)
79+
case s"org.xml.sax.$_" => super.loadClass(name) // XML SAX API (part of java.xml module)
80+
case s"org.w3c.dom.$_" => super.loadClass(name) // W3C DOM API (part of java.xml module)
81+
case s"com.sun.org.apache.$_" => super.loadClass(name) // Internal Xerces implementation
82+
// Don't instrument StopRepl, which would otherwise cause infinite recursion
7683
case "dotty.tools.repl.StopRepl" =>
7784
// Load StopRepl bytecode from parent but ensure each classloader gets its own copy
7885
val classFileName = name.replace('.', '/') + ".class"
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package dotty.tools.repl
2+
3+
import scala.language.unsafeNulls
4+
5+
import java.io.File
6+
import java.net.{URL, URLClassLoader}
7+
import scala.jdk.CollectionConverters.*
8+
import scala.util.control.NonFatal
9+
10+
import coursierapi.{Repository, Dependency, MavenRepository}
11+
import com.virtuslab.using_directives.UsingDirectivesProcessor
12+
import com.virtuslab.using_directives.custom.model.{Path, StringValue, Value}
13+
14+
/** Handles dependency resolution using Coursier for the REPL */
15+
object DependencyResolver:
16+
17+
/** Parse a dependency string of the form `org::artifact:version` or `org:artifact:version`
18+
* and return the (organization, artifact, version) triple if successful.
19+
*
20+
* Supports both Maven-style (single colon) and Scala-style (double colon) notation:
21+
* - Maven: `com.lihaoyi:scalatags_3:0.13.1`
22+
* - Scala: `com.lihaoyi::scalatags:0.13.1` (automatically appends _3)
23+
*/
24+
def parseDependency(dep: String): Option[(String, String, String)] =
25+
dep match
26+
case s"$org::$artifact:$version" => Some((org, s"${artifact}_3", version))
27+
case s"$org:$artifact:$version" => Some((org, artifact, version))
28+
case _ =>
29+
System.err.println("Unable to parse dependency \"" + dep + "\"")
30+
None
31+
32+
/** Extract all dependencies from using directives in source code */
33+
def extractDependencies(sourceCode: String): List[String] =
34+
try
35+
val directives = new UsingDirectivesProcessor().extract(sourceCode.toCharArray)
36+
val deps = scala.collection.mutable.Buffer[String]()
37+
38+
for
39+
directive <- directives.asScala
40+
(path, values) <- directive.getFlattenedMap.asScala
41+
do
42+
if path.getPath.asScala.toList == List("dep") then
43+
values.asScala.foreach {
44+
case strValue: StringValue => deps += strValue.get()
45+
case value => System.err.println("Unrecognized directive value " + value)
46+
}
47+
else
48+
System.err.println("Unrecognized directive " + path.getPath)
49+
50+
deps.toList
51+
catch
52+
case NonFatal(e) => Nil // If parsing fails, fall back to empty list
53+
54+
/** Resolve dependencies using Coursier Interface and return the classpath as a list of File objects */
55+
def resolveDependencies(dependencies: List[(String, String, String)]): Either[String, List[File]] =
56+
if dependencies.isEmpty then Right(Nil)
57+
else
58+
try
59+
// Add Maven Central and Sonatype repositories
60+
val repos = Array(
61+
MavenRepository.of("https://repo1.maven.org/maven2"),
62+
MavenRepository.of("https://oss.sonatype.org/content/repositories/releases")
63+
)
64+
65+
// Create dependency objects
66+
val deps = dependencies
67+
.map { case (org, artifact, version) => Dependency.of(org, artifact, version) }
68+
.toArray
69+
70+
val fetch = coursierapi.Fetch.create()
71+
.withRepositories(repos*)
72+
.withDependencies(deps*)
73+
74+
Right(fetch.fetch().asScala.toList)
75+
76+
catch
77+
case NonFatal(e) =>
78+
Left(s"Failed to resolve dependencies: ${e.getMessage}")
79+
80+
/** Add resolved dependencies to the compiler classpath and classloader.
81+
* Returns the new classloader.
82+
*
83+
* This follows the same pattern as the `:jar` command.
84+
*/
85+
def addToCompilerClasspath(
86+
files: List[File],
87+
prevClassLoader: ClassLoader,
88+
prevOutputDir: dotty.tools.io.AbstractFile
89+
)(using ctx: dotty.tools.dotc.core.Contexts.Context): AbstractFileClassLoader =
90+
import dotty.tools.dotc.classpath.ClassPathFactory
91+
import dotty.tools.dotc.core.SymbolLoaders
92+
import dotty.tools.dotc.core.Symbols.defn
93+
import dotty.tools.io.*
94+
import dotty.tools.runner.ScalaClassLoader.fromURLsParallelCapable
95+
96+
// Create a classloader with all the resolved JAR files
97+
val urls = files.map(_.toURI.toURL).toArray
98+
val depsClassLoader = new URLClassLoader(urls, prevClassLoader)
99+
100+
// Add each JAR to the compiler's classpath
101+
for file <- files do
102+
val jarFile = AbstractFile.getDirectory(file.getAbsolutePath)
103+
if jarFile != null then
104+
val jarClassPath = ClassPathFactory.newClassPath(jarFile)
105+
ctx.platform.addToClassPath(jarClassPath)
106+
SymbolLoaders.mergeNewEntries(defn.RootClass, ClassPath.RootPackage, jarClassPath, ctx.platform.classPath)
107+
108+
// Create new classloader with previous output dir and resolved dependencies
109+
new dotty.tools.repl.AbstractFileClassLoader(
110+
prevOutputDir,
111+
depsClassLoader,
112+
ctx.settings.XreplInterruptInstrumentation.value
113+
)
114+
115+
end DependencyResolver

repl/src/dotty/tools/repl/ParseResult.scala

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ sealed trait Command extends ParseResult
4141
/** An unknown command that will not be handled by the REPL */
4242
case class UnknownCommand(cmd: String) extends Command
4343

44+
case class Dep(dep: String) extends Command
45+
object Dep {
46+
val command: String = ":dep"
47+
}
4448
/** An ambiguous prefix that matches multiple commands */
4549
case class AmbiguousCommand(cmd: String, matchingCommands: List[String]) extends Command
4650

@@ -145,6 +149,7 @@ case object Help extends Command {
145149
|:reset [options] reset the repl to its initial state, forgetting all session entries
146150
|:settings <options> update compiler options, if possible
147151
|:silent disable/enable automatic printing of results
152+
|:dep <group>::<artifact>:<version> Resolve a dependency and make it available in the REPL
148153
""".stripMargin
149154
}
150155

@@ -169,6 +174,7 @@ object ParseResult {
169174
KindOf.command -> (arg => KindOf(arg)),
170175
Load.command -> (arg => Load(arg)),
171176
Require.command -> (arg => Require(arg)),
177+
Dep.command -> (arg => Dep(arg)),
172178
TypeOf.command -> (arg => TypeOf(arg)),
173179
DocOf.command -> (arg => DocOf(arg)),
174180
Settings.command -> (arg => Settings(arg)),

repl/src/dotty/tools/repl/ReplDriver.scala

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,8 +339,13 @@ class ReplDriver(settings: Array[String],
339339

340340
protected def interpret(res: ParseResult)(using state: State): State = {
341341
res match {
342+
case parsed: Parsed if parsed.source.content().mkString.startsWith("//>") =>
343+
// Check for magic comments specifying dependencies
344+
println("Please use `:dep com.example::artifact:version` to add dependencies in the REPL")
345+
state
346+
342347
case parsed: Parsed if parsed.trees.nonEmpty =>
343-
compile(parsed, state)
348+
compile(parsed, state)
344349

345350
case SyntaxErrors(_, errs, _) =>
346351
displayErrors(errs)
@@ -654,6 +659,27 @@ class ReplDriver(settings: Array[String],
654659
state.copy(context = rootCtx)
655660

656661
case Silent => state.copy(quiet = !state.quiet)
662+
case Dep(dep) =>
663+
val depStrings = List(dep)
664+
if depStrings.nonEmpty then
665+
val deps = depStrings.flatMap(DependencyResolver.parseDependency)
666+
if deps.nonEmpty then
667+
DependencyResolver.resolveDependencies(deps) match
668+
case Right(files) =>
669+
if files.nonEmpty then
670+
inContext(state.context):
671+
// Update both compiler classpath and classloader
672+
val prevOutputDir = ctx.settings.outputDir.value
673+
val prevClassLoader = rendering.classLoader()
674+
rendering.myClassLoader = DependencyResolver.addToCompilerClasspath(
675+
files,
676+
prevClassLoader,
677+
prevOutputDir
678+
)
679+
out.println(s"Resolved ${deps.size} dependencies (${files.size} JARs)")
680+
case Left(error) =>
681+
out.println(s"Error resolving dependencies: $error")
682+
state
657683

658684
case Quit =>
659685
// end of the world!

repl/test/dotty/tools/repl/TabcompleteTests.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ class TabcompleteTests extends ReplTest {
210210
@Test def commands = initially {
211211
assertEquals(
212212
List(
213+
":dep",
213214
":doc",
214215
":exit",
215216
":help",
@@ -232,7 +233,7 @@ class TabcompleteTests extends ReplTest {
232233
@Test def commandPreface = initially {
233234
// This looks odd, but if we return :doc here it will result in ::doc in the REPL
234235
assertEquals(
235-
List(":doc"),
236+
List(":dep", ":doc"),
236237
tabComplete(":d")
237238
)
238239
}

0 commit comments

Comments
 (0)