diff --git a/project/Build.scala b/project/Build.scala index 45bacb11c960..4d55f282523a 100644 --- a/project/Build.scala +++ b/project/Build.scala @@ -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 := { diff --git a/repl/src/dotty/tools/repl/AbstractFileClassLoader.scala b/repl/src/dotty/tools/repl/AbstractFileClassLoader.scala index c23a6bcd0268..1bde97621897 100644 --- a/repl/src/dotty/tools/repl/AbstractFileClassLoader.scala +++ b/repl/src/dotty/tools/repl/AbstractFileClassLoader.scala @@ -68,11 +68,18 @@ class AbstractFileClassLoader(val root: AbstractFile, parent: ClassLoader, inter val loaded = findLoadedClass(name) // Check if already loaded if loaded != null then return loaded - name match { // Don't instrument JDK classes or StopRepl + name match { + // Don't instrument JDK classes or StopRepl. These are often restricted to load from a single classloader + // due to the JDK module system, and so instrumenting them and loading the modified copy of the class + // results in runtime exceptions case s"java.$_" => super.loadClass(name) 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 + // Don't instrument StopRepl, which would otherwise cause infinite recursion case "dotty.tools.repl.StopRepl" => // Load StopRepl bytecode from parent but ensure each classloader gets its own copy val classFileName = name.replace('.', '/') + ".class" diff --git a/repl/src/dotty/tools/repl/DependencyResolver.scala b/repl/src/dotty/tools/repl/DependencyResolver.scala new file mode 100644 index 000000000000..732b69e1721b --- /dev/null +++ b/repl/src/dotty/tools/repl/DependencyResolver.scala @@ -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]] = + 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 diff --git a/repl/src/dotty/tools/repl/ParseResult.scala b/repl/src/dotty/tools/repl/ParseResult.scala index 24aa07c707a7..7b55e1c4968f 100644 --- a/repl/src/dotty/tools/repl/ParseResult.scala +++ b/repl/src/dotty/tools/repl/ParseResult.scala @@ -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 @@ -145,6 +149,7 @@ case object Help extends Command { |:reset [options] reset the repl to its initial state, forgetting all session entries |:settings update compiler options, if possible |:silent disable/enable automatic printing of results + |:dep ::: Resolve a dependency and make it available in the REPL """.stripMargin } @@ -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)), diff --git a/repl/src/dotty/tools/repl/ReplDriver.scala b/repl/src/dotty/tools/repl/ReplDriver.scala index 1dd5e5ea90b2..075f2555c592 100644 --- a/repl/src/dotty/tools/repl/ReplDriver.scala +++ b/repl/src/dotty/tools/repl/ReplDriver.scala @@ -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) @@ -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! diff --git a/repl/test/dotty/tools/repl/TabcompleteTests.scala b/repl/test/dotty/tools/repl/TabcompleteTests.scala index d3284402f58b..0541e25b2992 100644 --- a/repl/test/dotty/tools/repl/TabcompleteTests.scala +++ b/repl/test/dotty/tools/repl/TabcompleteTests.scala @@ -210,6 +210,7 @@ class TabcompleteTests extends ReplTest { @Test def commands = initially { assertEquals( List( + ":dep", ":doc", ":exit", ":help", @@ -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") ) }