Skip to content
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions project/Build.scala
Original file line number Diff line number Diff line change
Expand Up @@ -1835,6 +1835,8 @@ object Build {
"com.lihaoyi" %% "fansi" % "0.5.1",
"com.lihaoyi" %% "sourcecode" % "0.4.4",
"com.github.sbt" % "junit-interface" % "0.13.3" % Test,
"io.get-coursier" % "interface" % "1.0.28", // used by the REPL for dependency resolution
"org.virtuslab" % "using_directives" % "1.1.4", // used by the REPL for parsing magic comments
),
// Configure to use the non-bootstrapped compiler
scalaInstance := {
Expand Down
3 changes: 3 additions & 0 deletions repl/src/dotty/tools/repl/AbstractFileClassLoader.scala
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ class AbstractFileClassLoader(val root: AbstractFile, parent: ClassLoader, inter
case s"javax.$_" => super.loadClass(name)
case s"sun.$_" => super.loadClass(name)
case s"jdk.$_" => super.loadClass(name)
case s"org.xml.sax.$_" => super.loadClass(name) // XML SAX API (part of java.xml module)
case s"org.w3c.dom.$_" => super.loadClass(name) // W3C DOM API (part of java.xml module)
case s"com.sun.org.apache.$_" => super.loadClass(name) // Internal Xerces implementation
case "dotty.tools.repl.StopRepl" =>
// Load StopRepl bytecode from parent but ensure each classloader gets its own copy
val classFileName = name.replace('.', '/') + ".class"
Expand Down
115 changes: 115 additions & 0 deletions repl/src/dotty/tools/repl/DependencyResolver.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package dotty.tools.repl

import scala.language.unsafeNulls

import java.io.File
import java.net.{URL, URLClassLoader}
import scala.jdk.CollectionConverters.*
import scala.util.control.NonFatal

import coursierapi.{Repository, Dependency, MavenRepository}
import com.virtuslab.using_directives.UsingDirectivesProcessor
import com.virtuslab.using_directives.custom.model.{Path, StringValue, Value}

/** Handles dependency resolution using Coursier for the REPL */
object DependencyResolver:

/** Parse a dependency string of the form `org::artifact:version` or `org:artifact:version`
* and return the (organization, artifact, version) triple if successful.
*
* Supports both Maven-style (single colon) and Scala-style (double colon) notation:
* - Maven: `com.lihaoyi:scalatags_3:0.13.1`
* - Scala: `com.lihaoyi::scalatags:0.13.1` (automatically appends _3)
*/
def parseDependency(dep: String): Option[(String, String, String)] =
dep match
case s"$org::$artifact:$version" => Some((org, s"${artifact}_3", version))
case s"$org:$artifact:$version" => Some((org, artifact, version))
case _ =>
System.err.println("Unable to parse dependency \"" + dep + "\"")
None

/** Extract all dependencies from using directives in source code */
def extractDependencies(sourceCode: String): List[String] =
try
val directives = new UsingDirectivesProcessor().extract(sourceCode.toCharArray)
val deps = scala.collection.mutable.Buffer[String]()

for
directive <- directives.asScala
(path, values) <- directive.getFlattenedMap.asScala
do
if path.getPath.asScala.toList == List("dep") then
values.asScala.foreach {
case strValue: StringValue => deps += strValue.get()
case value => System.err.println("Unrecognized directive value " + value)
}
else
System.err.println("Unrecognized directive " + path.getPath)

deps.toList
catch
case NonFatal(e) => Nil // If parsing fails, fall back to empty list

/** Resolve dependencies using Coursier Interface and return the classpath as a list of File objects */
def resolveDependencies(dependencies: List[(String, String, String)]): Either[String, List[File]] =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be improved in the future by supporting:

//> using repository ...

https://scala-cli.virtuslab.org/docs/reference/directives#repository

if dependencies.isEmpty then Right(Nil)
else
try
// Add Maven Central and Sonatype repositories
val repos = Array(
MavenRepository.of("https://repo1.maven.org/maven2"),
MavenRepository.of("https://oss.sonatype.org/content/repositories/releases")
)

// Create dependency objects
val deps = dependencies
.map { case (org, artifact, version) => Dependency.of(org, artifact, version) }
.toArray

val fetch = coursierapi.Fetch.create()
.withRepositories(repos*)
.withDependencies(deps*)

Right(fetch.fetch().asScala.toList)

catch
case NonFatal(e) =>
Left(s"Failed to resolve dependencies: ${e.getMessage}")

/** Add resolved dependencies to the compiler classpath and classloader.
* Returns the new classloader.
*
* This follows the same pattern as the `:jar` command.
*/
def addToCompilerClasspath(
files: List[File],
prevClassLoader: ClassLoader,
prevOutputDir: dotty.tools.io.AbstractFile
)(using ctx: dotty.tools.dotc.core.Contexts.Context): AbstractFileClassLoader =
import dotty.tools.dotc.classpath.ClassPathFactory
import dotty.tools.dotc.core.SymbolLoaders
import dotty.tools.dotc.core.Symbols.defn
import dotty.tools.io.*
import dotty.tools.runner.ScalaClassLoader.fromURLsParallelCapable

// Create a classloader with all the resolved JAR files
val urls = files.map(_.toURI.toURL).toArray
val depsClassLoader = new URLClassLoader(urls, prevClassLoader)

// Add each JAR to the compiler's classpath
for file <- files do
val jarFile = AbstractFile.getDirectory(file.getAbsolutePath)
if jarFile != null then
val jarClassPath = ClassPathFactory.newClassPath(jarFile)
ctx.platform.addToClassPath(jarClassPath)
SymbolLoaders.mergeNewEntries(defn.RootClass, ClassPath.RootPackage, jarClassPath, ctx.platform.classPath)

// Create new classloader with previous output dir and resolved dependencies
new dotty.tools.repl.AbstractFileClassLoader(
prevOutputDir,
depsClassLoader,
ctx.settings.XreplInterruptInstrumentation.value
)

end DependencyResolver
6 changes: 6 additions & 0 deletions repl/src/dotty/tools/repl/ParseResult.scala
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ sealed trait Command extends ParseResult
/** An unknown command that will not be handled by the REPL */
case class UnknownCommand(cmd: String) extends Command

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

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

Expand All @@ -169,6 +174,7 @@ object ParseResult {
KindOf.command -> (arg => KindOf(arg)),
Load.command -> (arg => Load(arg)),
Require.command -> (arg => Require(arg)),
Dep.command -> (arg => Dep(arg)),
TypeOf.command -> (arg => TypeOf(arg)),
DocOf.command -> (arg => DocOf(arg)),
Settings.command -> (arg => Settings(arg)),
Expand Down
28 changes: 27 additions & 1 deletion repl/src/dotty/tools/repl/ReplDriver.scala
Original file line number Diff line number Diff line change
Expand Up @@ -339,8 +339,13 @@ class ReplDriver(settings: Array[String],

protected def interpret(res: ParseResult)(using state: State): State = {
res match {
case parsed: Parsed if parsed.source.content().mkString.startsWith("//>") =>
// Check for magic comments specifying dependencies
println("Please use `:dep com.example::artifact:version` to add dependencies in the REPL")
state

case parsed: Parsed if parsed.trees.nonEmpty =>
compile(parsed, state)
compile(parsed, state)

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

case Silent => state.copy(quiet = !state.quiet)
case Dep(dep) =>
val depStrings = List(dep)
if depStrings.nonEmpty then
val deps = depStrings.flatMap(DependencyResolver.parseDependency)
if deps.nonEmpty then
DependencyResolver.resolveDependencies(deps) match
case Right(files) =>
if files.nonEmpty then
inContext(state.context):
// Update both compiler classpath and classloader
val prevOutputDir = ctx.settings.outputDir.value
val prevClassLoader = rendering.classLoader()
rendering.myClassLoader = DependencyResolver.addToCompilerClasspath(
files,
prevClassLoader,
prevOutputDir
)
out.println(s"Resolved ${deps.size} dependencies (${files.size} JARs)")
case Left(error) =>
out.println(s"Error resolving dependencies: $error")
state

case Quit =>
// end of the world!
Expand Down
3 changes: 2 additions & 1 deletion repl/test/dotty/tools/repl/TabcompleteTests.scala
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ class TabcompleteTests extends ReplTest {
@Test def commands = initially {
assertEquals(
List(
":dep",
":doc",
":exit",
":help",
Expand All @@ -232,7 +233,7 @@ class TabcompleteTests extends ReplTest {
@Test def commandPreface = initially {
// This looks odd, but if we return :doc here it will result in ::doc in the REPL
assertEquals(
List(":doc"),
List(":dep", ":doc"),
tabComplete(":d")
)
}
Expand Down
Loading