Skip to content

Commit f369d9b

Browse files
committed
feat(repl): add resource directives and a :resource command
1 parent 71a210d commit f369d9b

9 files changed

Lines changed: 237 additions & 41 deletions

File tree

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,14 @@ object JarCmd extends ArgCommand[JarCmd] {
126126
val command: String = ":jar"
127127
}
128128

129+
/** `:resource <path>` adds a resource file or directory to the classpath
130+
*/
131+
case class ResourceCmd(path: String) extends Command:
132+
override def replayLine = Some(s"${ResourceCmd.command} $path")
133+
object ResourceCmd extends ArgCommand[ResourceCmd] {
134+
val command: String = ":resource"
135+
}
136+
129137
/** `:toolkit <version>` resolves a toolkit and adds it to the classpath
130138
*/
131139
case class ToolkitCmd(coordinates: String) extends Command:

repl/src/dotty/tools/repl/Rendering.scala

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -222,18 +222,18 @@ private[repl] class Rendering(parentClassLoader: Option[ClassLoader] = None):
222222
if (myClassLoader != null && myClassLoader.root == ctx.settings.outputDir.value) myClassLoader
223223
else {
224224
val parent = Option(myClassLoader).getOrElse {
225-
val base = parentClassLoader.getOrElse {
226-
val compilerClasspath = ctx.platform.classPath(using ctx).asURLs
227-
// We can't use the system classloader as a parent because it would
228-
// pollute the user classpath with everything passed to the JVM
229-
// `-classpath`. We can't use `null` as a parent either because on Java
230-
// 9+ that's the bootstrap classloader which doesn't contain modules
231-
// like `java.sql`, so we use the parent of the system classloader,
232-
// which should correspond to the platform classloader on Java 9+.
233-
val baseClassLoader = ClassLoader.getSystemClassLoader.getParent
234-
new URLClassLoader(compilerClasspath.toArray, baseClassLoader)
235-
}
236-
myClasspathClassLoader = ClasspathClassLoader(base)
225+
myClasspathClassLoader = parentClassLoader match
226+
case Some(given_) => ClasspathClassLoader(Array.empty, given_)
227+
case None =>
228+
val compilerClasspath = ctx.platform.classPath(using ctx).asURLs
229+
// We can't use the system classloader as a parent because it would
230+
// pollute the user classpath with everything passed to the JVM
231+
// `-classpath`. We can't use `null` as a parent either because on Java
232+
// 9+ that's the bootstrap classloader which doesn't contain modules
233+
// like `java.sql`, so we use the parent of the system classloader,
234+
// which should correspond to the platform classloader on Java 9+.
235+
val baseClassLoader = ClassLoader.getSystemClassLoader.getParent
236+
ClasspathClassLoader(compilerClasspath.toArray, baseClassLoader)
237237
myClasspathClassLoader
238238
}
239239

@@ -249,6 +249,8 @@ private[repl] class Rendering(parentClassLoader: Option[ClassLoader] = None):
249249
classLoader()
250250
urls.foreach(myClasspathClassLoader.add)
251251

252+
private[repl] def addResource(url: URL)(using Context): Unit = addToClasspath(Seq(url))
253+
252254
private[repl] def truncate(str: String, maxPrintCharacters: Int)(using ctx: Context): String =
253255
val ncp = str.codePointCount(0, str.length) // to not cut inside code point
254256
if ncp <= maxPrintCharacters then str
@@ -381,5 +383,6 @@ object Rendering:
381383
rootCause(x.getCause)
382384
case _ => x
383385

384-
private class ClasspathClassLoader(parent: ClassLoader) extends URLClassLoader(Array.empty, parent):
386+
private class ClasspathClassLoader(urls: Array[URL], parent: ClassLoader)
387+
extends URLClassLoader(urls, parent):
385388
def add(url: URL): Unit = addURL(url)

repl/src/dotty/tools/repl/ReplCommands.scala

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -40,21 +40,22 @@ private[repl] object ReplCommands:
4040
CommandDefinition(companion)
4141

4242
private val definitions = List(
43-
command(Help, "print this summary"),
44-
command(Save, "save replayable session to a file", "<path>"),
45-
command(Load, "interpret lines in a file", "<path>"),
46-
command(Quit, "exit the interpreter", aliases = List(Quit.alias)),
47-
command(TypeOf, "evaluate the type of the given expression", "<expression>"),
48-
command(DocOf, "print the documentation for the given expression", "<expression>"),
49-
command(Imports, "show import history"),
50-
command(Reset, "clear the session and start fresh with the given compiler options", "[options]"),
51-
command(Replay, "reset, then re-run the session with the given compiler options", "[options]"),
52-
command(Settings, "update compiler options, if possible", "<options>"),
53-
command(Silent, "disable/enable automatic printing of results"),
54-
command(JarCmd, "add a JAR to the classpath", "<path>"),
55-
command(Dep, "resolve a dependency and make it available in the REPL", "<group>::<artifact>:<version>"),
56-
command(ToolkitCmd, "resolve a toolkit and make it available in the REPL", "<version>|default|<flavor>:<version>"),
57-
command(RepoCmd, "add repositories used to resolve dependencies", "<url>|<alias>"),
43+
command(Help, "print this summary"),
44+
command(Save, "save replayable session to a file", "<path>"),
45+
command(Load, "interpret lines in a file", "<path>"),
46+
command(Quit, "exit the interpreter", aliases = List(Quit.alias)),
47+
command(TypeOf, "evaluate the type of the given expression", "<expression>"),
48+
command(DocOf, "print the documentation for the given expression", "<expression>"),
49+
command(Imports, "show import history"),
50+
command(Reset, "clear the session and start fresh with the given compiler options", "[options]"),
51+
command(Replay, "reset, then re-run the session with the given compiler options", "[options]"),
52+
command(Settings, "update compiler options, if possible", "<options>"),
53+
command(Silent, "disable/enable automatic printing of results"),
54+
command(JarCmd, "add a JAR to the classpath", "<path>"),
55+
command(Dep, "resolve a dependency and make it available in the REPL", "<group>::<artifact>:<version>"),
56+
command(ResourceCmd, "add a resource file or directory to the classpath", "<path>"),
57+
command(ToolkitCmd, "resolve a toolkit and make it available in the REPL", "<version>|default|<flavor>:<version>"),
58+
command(RepoCmd, "add repositories used to resolve dependencies", "<url>|<alias>"),
5859
hidden(Sh),
5960
hidden(KindOf),
6061
hidden(Require),

repl/src/dotty/tools/repl/ReplDirectives.scala

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,17 @@ private[repl] object ReplDirectives:
1010
handlersByKey.get(key).fold("")(handler => s"\nUsage: ${handler.usage}")
1111

1212
enum Warning:
13-
case NoSeparateTestScope
13+
case NoSeparateTestScope(what: String)
1414
case TestToolkitSameAsToolkit
1515
case ValueMissing(key: String)
1616
case TooManyValues(key: String)
1717
case MalformedValue(key: String, value: String)
1818
case UnsupportedDirective(key: String)
1919

2020
override def toString: String = this match
21-
case NoSeparateTestScope =>
22-
"""[warn] The REPL does not have a separate test scope. Dependencies that would only be
23-
|available to tests are added to the current REPL session.""".stripMargin
21+
case NoSeparateTestScope(what) =>
22+
s"""[warn] The REPL does not have a separate test scope. $what that would only be
23+
|available to tests are added to the current REPL session.""".stripMargin
2424
case TestToolkitSameAsToolkit =>
2525
"""[warn] The REPL does not have a separate test scope, so `using test.toolkit` adds
2626
|exactly what `using toolkit` does.""".stripMargin
@@ -38,6 +38,7 @@ private[repl] object ReplDirectives:
3838
case Dependency(coordinate: String)
3939
case Jar(path: String)
4040
case Repository(repository: String)
41+
case Resource(path: String)
4142

4243
case class DirectiveClassification(
4344
directives: List[ReplDirective],
@@ -97,7 +98,7 @@ private[repl] object ReplDirectives:
9798
usage = "//> using test.dep <group>::<artifact>:<version> ...",
9899
description = "Resolve dependencies and make them available in the REPL.",
99100
toDirectives = coords => List(ReplDirective.Dependency(coords)),
100-
warnings = List(Warning.NoSeparateTestScope)
101+
warnings = List(Warning.NoSeparateTestScope("Dependencies"))
101102
)
102103

103104
case Jar extends DirectiveHandler(
@@ -107,6 +108,21 @@ private[repl] object ReplDirectives:
107108
toDirectives = path => List(ReplDirective.Jar(path))
108109
)
109110

111+
case Resource extends DirectiveHandler(
112+
keys = List("resourceDir", "resourceDirs", "resource"),
113+
usage = "//> using resourceDir <path> ...",
114+
description = "Add resource files or directories to the REPL classpath.",
115+
toDirectives = path => List(ReplDirective.Resource(path))
116+
)
117+
118+
case TestResource extends DirectiveHandler(
119+
keys = List("test.resourceDir", "test.resourceDirs", "test.resource"),
120+
usage = "//> using test.resourceDir <path> ...",
121+
description = "Add resource files or directories to the REPL classpath.",
122+
toDirectives = path => List(ReplDirective.Resource(path)),
123+
warnings = List(Warning.NoSeparateTestScope("Resources"))
124+
)
125+
110126
case Toolkit extends DirectiveHandler(
111127
keys = List("toolkit"),
112128
usage = "//> using toolkit <version>|default|<flavor>:<version>",
@@ -115,7 +131,7 @@ private[repl] object ReplDirectives:
115131
| Known flavors: scala (default, ${ScalaToolkit.defaultVersion}),
116132
| typelevel (${TypelevelToolkit.defaultVersion}).""".stripMargin,
117133
toDirectives = toolkitDependencies,
118-
warnings = List(Warning.NoSeparateTestScope),
134+
warnings = List(Warning.NoSeparateTestScope("Dependencies")),
119135
acceptsMultipleValues = false
120136
)
121137

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

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import scala.util.control.NonFatal
55

66
import java.io.{File => JFile, PrintStream}
77
import java.nio.charset.StandardCharsets
8-
import java.nio.file.Files
8+
import java.nio.file.{Files, Path}
99
import java.util.regex.Pattern
1010

1111
import dotc.ast.Trees.*
@@ -278,7 +278,7 @@ class ReplDriver(settings: Array[String],
278278
try
279279
System.setIn(replIn)
280280
scala.Console.withIn(replIn) {
281-
interpret(res)
281+
interpretSubmission(res)
282282
}
283283
finally
284284
System.setIn(savedIn)
@@ -296,6 +296,9 @@ class ReplDriver(settings: Array[String],
296296
interpret(ParseResult.complete(input))
297297
}
298298

299+
protected final def interpretSubmission(res: ParseResult)(using state: State): State =
300+
rendering.classLoader()(using state.context).asContext(interpret(res))
301+
299302
protected def runBody(body: => State): State = rendering.classLoader()(using rootCtx).asContext(withRedirectedOutput(body))
300303

301304
// TODO: i5069
@@ -431,7 +434,8 @@ class ReplDriver(settings: Array[String],
431434
case CommandThenCode(cmd, code) =>
432435
val stateAfterCommand = interpretCommand(cmd)
433436
val recorded = cmd.replayLine.fold(stateAfterCommand)(line => stateAfterCommand.recordInput(line.strip))
434-
interpret(ParseResult(code)(using recorded))(using recorded)
437+
rendering.classLoader()(using recorded.context).asContext:
438+
interpret(ParseResult(code)(using recorded))(using recorded)
435439

436440
case MixedCommandsAndDirectives =>
437441
out.println(
@@ -749,6 +753,8 @@ class ReplDriver(settings: Array[String],
749753
}
750754
state
751755

756+
case ResourceCmd(path) => addResource(path)
757+
752758
case KindOf(expr) =>
753759
out.println(s"""The :kind command is not currently supported.""")
754760
state
@@ -818,7 +824,7 @@ class ReplDriver(settings: Array[String],
818824
case _ => None
819825
singleValue.flatMap(ReplDirectives.toolkitCoordinates) match
820826
case Some(dependencies) =>
821-
out.println(ReplDirectives.Warning.NoSeparateTestScope.toString)
827+
out.println(ReplDirectives.Warning.NoSeparateTestScope("Dependencies").toString)
822828
resolveAndAddDeps(dependencies)
823829
case None =>
824830
out.println(
@@ -841,10 +847,35 @@ class ReplDriver(settings: Array[String],
841847
case Jar(path) => path
842848
val repositories = classified.directives.collect:
843849
case Repository(repository) => repository
850+
val resources = classified.directives.collect:
851+
case Resource(path) => path
844852
val stateWithRepositories = addRepositories(repositories)
845853
val stateWithDependencies = resolveAndAddDeps(dependencies)(using stateWithRepositories)
846-
jars.foldLeft(stateWithDependencies): (currentState, path) =>
854+
val stateWithJars = jars.foldLeft(stateWithDependencies): (currentState, path) =>
847855
interpretCommand(JarCmd(path))(using currentState)
856+
resources.foldLeft(stateWithJars): (currentState, path) =>
857+
addResource(path)(using currentState)
858+
859+
private def addResource(path: String)(using state: State): State =
860+
try
861+
val resource = Path.of(path)
862+
if !Files.exists(resource) then
863+
out.println(s"Cannot add '$path' to classpath, it does not exist.")
864+
else
865+
val root = if Files.isDirectory(resource) then resource else stageResourceFile(resource)
866+
inContext(state.context):
867+
rendering.addResource(root.toUri.toURL)
868+
out.println(s"Added '$path' to classpath.")
869+
catch case NonFatal(e) =>
870+
out.println(s"Failed to load '$path' to classpath: ${e.getMessage}")
871+
state
872+
873+
private def stageResourceFile(resource: Path): Path =
874+
val staging = Files.createTempDirectory("repl_resource")
875+
staging.toFile.deleteOnExit()
876+
val staged = Files.copy(resource, staging.resolve(resource.getFileName))
877+
staged.toFile.deleteOnExit()
878+
staging
848879

849880
private def addRepositories(repositoryStrings: List[String])(using state: State): State =
850881
repositoryStrings.foldLeft(state): (currentState, repositoryString) =>

repl/test/dotty/tools/repl/ReplDirectiveTests.scala

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ package repl
44
import org.junit.Assert.{assertEquals, assertFalse, assertTrue}
55
import org.junit.Test
66

7-
import ReplDirectives.ReplDirective.{Dependency, Jar, Repository}
7+
import ReplDirectives.ReplDirective.{Dependency, Jar, Repository, Resource}
88
import ReplDirectives.Warning
99

1010
class ReplDirectiveTests extends ReplTest, SessionFileHelpers:
@@ -24,7 +24,7 @@ class ReplDirectiveTests extends ReplTest, SessionFileHelpers:
2424
aliases.foreach: alias =>
2525
val result = ReplDirectives.classify(s"//> using $alias ${dependencies.mkString(" ")}")
2626
assertEquals(dependencies.map(Dependency(_)), result.directives)
27-
assertEquals(List(Warning.NoSeparateTestScope), result.warnings)
27+
assertEquals(List(Warning.NoSeparateTestScope("Dependencies")), result.warnings)
2828
assertTrue(ReplDirectives.helpText.contains("Aliases: test.deps, test.dependency, test.dependencies"))
2929

3030
@Test def `jar directive aliases are supported`: Unit =
@@ -35,6 +35,22 @@ class ReplDirectiveTests extends ReplTest, SessionFileHelpers:
3535
assertEquals(Nil, result.warnings)
3636
assertTrue(ReplDirectives.helpText.contains("Aliases: jars"))
3737

38+
@Test def `resource directive aliases are supported`: Unit =
39+
val paths = List("res", "conf.json")
40+
List("resourceDir", "resourceDirs", "resource").foreach: alias =>
41+
val result = ReplDirectives.classify(s"//> using $alias ${paths.mkString(" ")}")
42+
assertEquals(alias, paths.map(Resource(_)), result.directives)
43+
assertEquals(alias, Nil, result.warnings)
44+
assertTrue(ReplDirectives.helpText.contains("Aliases: resourceDirs, resource"))
45+
46+
@Test def `test resource directive aliases are supported with a warning`: Unit =
47+
val paths = List("res", "conf.json")
48+
List("test.resourceDir", "test.resourceDirs", "test.resource").foreach: alias =>
49+
val result = ReplDirectives.classify(s"//> using $alias ${paths.mkString(" ")}")
50+
assertEquals(alias, paths.map(Resource(_)), result.directives)
51+
assertEquals(alias, List(Warning.NoSeparateTestScope("Resources")), result.warnings)
52+
assertTrue(ReplDirectives.helpText.contains("Aliases: test.resourceDirs, test.resource"))
53+
3854
@Test def `repository directive aliases are supported`: Unit =
3955
val repositories = List("m2Local", "https://jitpack.io")
4056
List("repository", "repositories").foreach: alias =>
@@ -93,6 +109,14 @@ class ReplDirectiveTests extends ReplTest, SessionFileHelpers:
93109
storedOutput().trim
94110
)
95111

112+
@Test def `resourceDir directive applies to the code that follows it`: Unit =
113+
val dir = resourceDir("greeting.txt", "hello")
114+
initially:
115+
run(s"""//> using resourceDir $dir
116+
|val greeting = scala.io.Source.fromResource("greeting.txt").mkString""".stripMargin)
117+
val output = storedOutput()
118+
assertTrue(output, output.contains("""val greeting: String = "hello""""))
119+
96120
@Test def `test dependency directive warns about the shared REPL scope`: Unit =
97121
initially:
98122
run("//> using test.dep org.scalameta::munit:1.1.1")

0 commit comments

Comments
 (0)