diff --git a/compiler/src/dotty/tools/scripting/Main.scala b/compiler/src/dotty/tools/scripting/Main.scala new file mode 100644 index 000000000000..f820421860a6 --- /dev/null +++ b/compiler/src/dotty/tools/scripting/Main.scala @@ -0,0 +1,22 @@ +package dotty.tools.scripting + +import java.io.File + +/** Main entry point to the Scripting execution engine */ +object Main: + /** All arguments before -script are compiler arguments. + All arguments afterwards are script arguments.*/ + def distinguishArgs(args: Array[String]): (Array[String], File, Array[String]) = + val (compilerArgs, rest) = args.splitAt(args.indexOf("-script")) + val file = File(rest(1)) + val scriptArgs = rest.drop(2) + (compilerArgs, file, scriptArgs) + end distinguishArgs + + def main(args: Array[String]): Unit = + val (compilerArgs, scriptFile, scriptArgs) = distinguishArgs(args) + try ScriptingDriver(compilerArgs, scriptFile, scriptArgs).compileAndRun() + catch + case ScriptingException(msg) => + println(s"Error: $msg") + sys.exit(1) diff --git a/compiler/src/dotty/tools/scripting/ScriptingDriver.scala b/compiler/src/dotty/tools/scripting/ScriptingDriver.scala new file mode 100644 index 000000000000..d5fad4ef520a --- /dev/null +++ b/compiler/src/dotty/tools/scripting/ScriptingDriver.scala @@ -0,0 +1,86 @@ +package dotty.tools.scripting + +import java.nio.file.{ Files, Path } +import java.io.File +import java.net.{ URL, URLClassLoader } +import java.lang.reflect.{ Modifier, Method } + +import scala.jdk.CollectionConverters._ + +import dotty.tools.dotc.{ Driver, Compiler } +import dotty.tools.dotc.core.Contexts, Contexts.{ Context, ContextBase, ctx } +import dotty.tools.dotc.config.CompilerCommand +import dotty.tools.io.{ PlainDirectory, Directory } +import dotty.tools.dotc.reporting.Reporter +import dotty.tools.dotc.config.Settings.Setting._ + +import sys.process._ + +class ScriptingDriver(compilerArgs: Array[String], scriptFile: File, scriptArgs: Array[String]) extends Driver: + def compileAndRun(): Unit = + val outDir = Files.createTempDirectory("scala3-scripting") + val (toCompile, rootCtx) = setup(compilerArgs :+ scriptFile.getAbsolutePath, initCtx.fresh) + given Context = rootCtx.fresh.setSetting(rootCtx.settings.outputDir, + new PlainDirectory(Directory(outDir))) + + if doCompile(newCompiler, toCompile).hasErrors then + throw ScriptingException("Errors encountered during compilation") + + try detectMainMethod(outDir, ctx.settings.classpath.value).invoke(null, scriptArgs) + catch + case e: java.lang.reflect.InvocationTargetException => + throw e.getCause + finally + deleteFile(outDir.toFile) + end compileAndRun + + private def deleteFile(target: File): Unit = + if target.isDirectory then + for member <- target.listFiles.toList + do deleteFile(member) + target.delete() + end deleteFile + + private def detectMainMethod(outDir: Path, classpath: String): Method = + val outDirURL = outDir.toUri.toURL + val classpathUrls = classpath.split(":").map(File(_).toURI.toURL) + val cl = URLClassLoader(classpathUrls :+ outDirURL) + + def collectMainMethods(target: File, path: String): List[Method] = + val nameWithoutExtension = target.getName.takeWhile(_ != '.') + val targetPath = + if path.nonEmpty then s"${path}.${nameWithoutExtension}" + else nameWithoutExtension + + if target.isDirectory then + for + packageMember <- target.listFiles.toList + membersMainMethod <- collectMainMethods(packageMember, targetPath) + yield membersMainMethod + else if target.getName.endsWith(".class") then + val cls = cl.loadClass(targetPath) + try + val method = cls.getMethod("main", classOf[Array[String]]) + if Modifier.isStatic(method.getModifiers) then List(method) else Nil + catch + case _: java.lang.NoSuchMethodException => Nil + else Nil + end collectMainMethods + + val candidates = for + file <- outDir.toFile.listFiles.toList + method <- collectMainMethods(file, "") + yield method + + candidates match + case Nil => + throw ScriptingException("No main methods detected in your script") + case _ :: _ :: _ => + throw ScriptingException("A script must contain only one main method. " + + s"Detected the following main methods:\n${candidates.mkString("\n")}") + case m :: Nil => m + end match + end detectMainMethod +end ScriptingDriver + +case class ScriptingException(msg: String) extends RuntimeException(msg) diff --git a/compiler/test-resources/scripting/basic-math.scala b/compiler/test-resources/scripting/basic-math.scala new file mode 100644 index 000000000000..a8c16684ce78 --- /dev/null +++ b/compiler/test-resources/scripting/basic-math.scala @@ -0,0 +1 @@ +@main def Test = assert(2 + 2 == 4) \ No newline at end of file diff --git a/compiler/test-resources/scripting/say-hi.args b/compiler/test-resources/scripting/say-hi.args new file mode 100644 index 000000000000..beef906c3e3b --- /dev/null +++ b/compiler/test-resources/scripting/say-hi.args @@ -0,0 +1 @@ +World \ No newline at end of file diff --git a/compiler/test-resources/scripting/say-hi.scala b/compiler/test-resources/scripting/say-hi.scala new file mode 100644 index 000000000000..9650a6744591 --- /dev/null +++ b/compiler/test-resources/scripting/say-hi.scala @@ -0,0 +1,2 @@ +@main def Test(name: String) = + assert(name == "World") diff --git a/compiler/test/dotty/tools/repl/ReplTest.scala b/compiler/test/dotty/tools/repl/ReplTest.scala index 023e2e5aba4e..aa884dad8712 100644 --- a/compiler/test/dotty/tools/repl/ReplTest.scala +++ b/compiler/test/dotty/tools/repl/ReplTest.scala @@ -50,12 +50,6 @@ class ReplTest(withStaging: Boolean = false, out: ByteArrayOutputStream = new By extension [A](state: State) def andThen(op: State => A): A = op(state) - def scripts(path: String): Array[JFile] = { - val dir = new JFile(getClass.getResource(path).getPath) - assert(dir.exists && dir.isDirectory, "Couldn't load scripts dir") - dir.listFiles - } - def testFile(f: JFile): Unit = { val prompt = "scala>" @@ -77,12 +71,11 @@ class ReplTest(withStaging: Boolean = false, out: ByteArrayOutputStream = new By case nonEmptyLine => nonEmptyLine :: Nil } - val expectedOutput = - Using(Source.fromFile(f, StandardCharsets.UTF_8.name))(_.getLines().flatMap(filterEmpties).toList).get + val expectedOutput = readLines(f).flatMap(filterEmpties) val actualOutput = { resetToInitial() - val lines = Using(Source.fromFile(f, StandardCharsets.UTF_8.name))(_.getLines.toList).get + val lines = readLines(f) assert(lines.head.startsWith(prompt), s"""Each file has to start with the prompt: "$prompt"""") val inputRes = lines.filter(_.startsWith(prompt)) diff --git a/compiler/test/dotty/tools/scripting/ScriptingTests.scala b/compiler/test/dotty/tools/scripting/ScriptingTests.scala new file mode 100644 index 000000000000..547f525e98e4 --- /dev/null +++ b/compiler/test/dotty/tools/scripting/ScriptingTests.scala @@ -0,0 +1,39 @@ +package dotty +package tools +package scripting + +import java.io.File + +import org.junit.Test + +import vulpix.TestConfiguration + + +/** Runs all tests contained in `compiler/test-resources/repl/` */ +class ScriptingTests: + extension (str: String) def dropExtension = + str.reverse.dropWhile(_ != '.').drop(1).reverse + + @Test def scriptingTests = + val testFiles = scripts("/scripting") + + val argss: Map[String, Array[String]] = ( + for + argFile <- testFiles + if argFile.getName.endsWith(".args") + name = argFile.getName.dropExtension + scriptArgs = readLines(argFile).toArray + yield name -> scriptArgs).toMap + + for + scriptFile <- testFiles + if scriptFile.getName.endsWith(".scala") + name = scriptFile.getName.dropExtension + scriptArgs = argss.getOrElse(name, Array.empty[String]) + do + ScriptingDriver( + compilerArgs = Array( + "-classpath", TestConfiguration.basicClasspath), + scriptFile = scriptFile, + scriptArgs = scriptArgs + ).compileAndRun() diff --git a/compiler/test/dotty/tools/utils.scala b/compiler/test/dotty/tools/utils.scala new file mode 100644 index 000000000000..b7023a577234 --- /dev/null +++ b/compiler/test/dotty/tools/utils.scala @@ -0,0 +1,19 @@ +package dotty.tools + +import java.io.File +import java.nio.charset.StandardCharsets.UTF_8 + +import scala.io.Source +import scala.util.Using.resource + +def scripts(path: String): Array[File] = { + val dir = new File(getClass.getResource(path).getPath) + assert(dir.exists && dir.isDirectory, "Couldn't load scripts dir") + dir.listFiles +} + +private def withFile[T](file: File)(action: Source => T): T = + resource(Source.fromFile(file, UTF_8.name))(action) + +def readLines(f: File): List[String] = withFile(f)(_.getLines.toList) +def readFile(f: File): String = withFile(f)(_.mkString) diff --git a/dist/bin/scala b/dist/bin/scala index a00ab14a7fac..3c522a082d3b 100755 --- a/dist/bin/scala +++ b/dist/bin/scala @@ -38,8 +38,10 @@ addDotcOptions () { source "$PROG_HOME/bin/common" declare -a residual_args +declare -a script_args execute_repl=false execute_run=false +execute_script=false with_compiler=false class_path_count=0 CLASS_PATH="" @@ -79,13 +81,28 @@ while [[ $# -gt 0 ]]; do addDotcOptions "${1}" shift ;; *) - residual_args+=("$1") + if [ $execute_script == false ]; then + if [[ "$1" == *.scala ]]; then + execute_script=true + target_script="$1" + else + residual_args+=("$1") + fi + else + script_args+=("$1") + fi shift ;; esac done -if [ $execute_repl == true ] || ([ $execute_run == false ] && [ $options_indicator == 0 ]); then + +if [ $execute_script == true ]; then + if [ "$CLASS_PATH" ]; then + cp_arg="-classpath \"$CLASS_PATH\"" + fi + eval "\"$PROG_HOME/bin/scalac\" $cp_arg ${java_options[@]} ${residual_args[@]} -script $target_script ${script_args[@]}" +elif [ $execute_repl == true ] || ([ $execute_run == false ] && [ $options_indicator == 0 ]); then if [ "$CLASS_PATH" ]; then cp_arg="-classpath \"$CLASS_PATH\"" fi diff --git a/dist/bin/scalac b/dist/bin/scalac index 491add6c1b7d..148a7b8cfbba 100755 --- a/dist/bin/scalac +++ b/dist/bin/scalac @@ -33,6 +33,7 @@ withCompiler=true CompilerMain=dotty.tools.dotc.Main DecompilerMain=dotty.tools.dotc.decompiler.Main ReplMain=dotty.tools.repl.Main +ScriptingMain=dotty.tools.scripting.Main PROG_NAME=$CompilerMain @@ -45,6 +46,9 @@ addScala () { addResidual () { residual_args+=("'$1'") } +addScripting () { + scripting_args+=("'$1'") +} classpathArgs () { # echo "dotty-compiler: $DOTTY_COMP" @@ -74,6 +78,7 @@ classpathArgs () { jvm_cp_args="-classpath \"$toolchain\"" } +in_scripting_args=false while [[ $# -gt 0 ]]; do case "$1" in --) shift; for arg; do addResidual "$arg"; done; set -- ;; @@ -85,6 +90,7 @@ case "$1" in # Optimize for short-running applications, see https://github.com/lampepfl/dotty/issues/222 -Oshort) addJava "-XX:+TieredCompilation -XX:TieredStopAtLevel=1" && shift ;; -repl) PROG_NAME="$ReplMain" && shift ;; + -script) PROG_NAME="$ScriptingMain" && target_script="$2" && in_scripting_args=true && shift && shift ;; -compile) PROG_NAME="$CompilerMain" && shift ;; -decompile) PROG_NAME="$DecompilerMain" && shift ;; -print-tasty) PROG_NAME="$DecompilerMain" && addScala "-print-tasty" && shift ;; @@ -98,12 +104,22 @@ case "$1" in # will be available as system properties. -D*) addJava "$1" && shift ;; -J*) addJava "${1:2}" && shift ;; - *) addResidual "$1" && shift ;; + *) if [ $in_scripting_args == false ]; then + addResidual "$1" + else + addScripting "$1" + fi + shift + ;; esac done classpathArgs +if [ "$PROG_NAME" == "$ScriptingMain" ]; then + scripting_string="-script $target_script ${scripting_args[@]}" +fi + eval exec "\"$JAVACMD\"" \ ${JAVA_OPTS:-$default_java_opts} \ "$DEBUG" \ @@ -112,5 +128,6 @@ eval exec "\"$JAVACMD\"" \ -Dscala.usejavacp=true \ "$PROG_NAME" \ "${scala_args[@]}" \ - "${residual_args[@]}" + "${residual_args[@]}" \ + "$scripting_string" exit $? diff --git a/docs/docs/usage/getting-started.md b/docs/docs/usage/getting-started.md index 634263c502ca..ab576ecd3acf 100644 --- a/docs/docs/usage/getting-started.md +++ b/docs/docs/usage/getting-started.md @@ -74,3 +74,19 @@ In case you have already installed Dotty via brew, you should instead update it: ```bash brew upgrade dotty ``` + +### Scala 3 for Scripting +If you have followed the steps in "Standalone Installation" section and have the `scala` executable on your `PATH`, you can run `*.scala` files as scripts. Given a source named Test.scala: + +```scala +@main def Test(name: String): Unit = + println(s"Hello ${name}!") +``` + +You can run: `scala Test.scala World` to get an output `Hello World!`. + +A "script" is an ordinary Scala file which contains a main method. The semantics of the `scala Script.scala` command is as follows: + +- Compile `Script.scala` with `scalac` into a temporary directory. +- Detect the main method in the `*.class` files produced by the compilation. +- Execute the main method. diff --git a/staging/test/scala/quoted/staging/repl/StagingScriptedReplTests.scala b/staging/test/scala/quoted/staging/repl/StagingScriptedReplTests.scala index 4aa50a835aa6..c7abbec41f09 100644 --- a/staging/test/scala/quoted/staging/repl/StagingScriptedReplTests.scala +++ b/staging/test/scala/quoted/staging/repl/StagingScriptedReplTests.scala @@ -1,6 +1,7 @@ package scala.quoted.staging.repl import dotty.BootstrappedOnlyTests +import dotty.tools.scripts import dotty.tools.repl.ReplTest import dotty.tools.vulpix.TestConfiguration import org.junit.Test