Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add eval (-e) expression evaluation to command line #14263

Merged
merged 3 commits into from Jan 20, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
28 changes: 24 additions & 4 deletions compiler/src/dotty/tools/MainGenericRunner.scala
Expand Up @@ -4,28 +4,26 @@ package dotty.tools
import scala.annotation.tailrec
import scala.io.Source
import scala.util.{ Try, Success, Failure }
import java.net.URLClassLoader
import sys.process._
import java.io.File
import java.lang.Thread
import scala.annotation.internal.sharable
import dotty.tools.dotc.util.ClasspathFromClassloader
import dotty.tools.runner.ObjectRunner
import dotty.tools.dotc.config.Properties.envOrNone
import java.util.jar._
import java.util.jar.Attributes.Name
import dotty.tools.io.Jar
import dotty.tools.runner.ScalaClassLoader
import java.nio.file.{Files, Paths, Path}
import scala.collection.JavaConverters._
import dotty.tools.dotc.config.CommandLineParser
import dotty.tools.scripting.StringDriver

enum ExecuteMode:
case Guess
case Script
case Repl
case Run
case PossibleRun
case Expression

case class Settings(
verbose: Boolean = false,
Expand All @@ -38,6 +36,7 @@ case class Settings(
possibleEntryPaths: List[String] = List.empty,
scriptArgs: List[String] = List.empty,
targetScript: String = "",
targetExpression: String = "",
targetToRun: String = "",
save: Boolean = false,
modeShouldBePossibleRun: Boolean = false,
Expand Down Expand Up @@ -78,6 +77,9 @@ case class Settings(
def withTargetToRun(targetToRun: String): Settings =
this.copy(targetToRun = targetToRun)

def withExpression(scalaSource: String): Settings =
this.copy(targetExpression = scalaSource)

def withSave: Settings =
this.copy(save = true)

Expand Down Expand Up @@ -149,6 +151,13 @@ object MainGenericRunner {
process(remainingArgs, settings)
case (o @ colorOption(_*)) :: tail =>
process(tail, settings.withScalaArgs(o))
case "-e" :: expression :: tail =>
val mainSource = s"@main def main(args: String *): Unit =\n ${expression}"
settings
.withExecuteMode(ExecuteMode.Expression)
.withExpression(mainSource)
.withScriptArgs(tail*)
.noSave // -save not useful here
case arg :: tail =>
val line = Try(Source.fromFile(arg).getLines.toList).toOption.flatMap(_.headOption)
lazy val hasScalaHashbang = { val s = line.getOrElse("") ; s.startsWith("#!") && s.contains("scala") }
Expand All @@ -161,6 +170,7 @@ object MainGenericRunner {
val newSettings = if arg.startsWith("-") then settings else settings.withPossibleEntryPaths(arg).withModeShouldBePossibleRun
process(tail, newSettings.withResidualArgs(arg))


def main(args: Array[String]): Unit =
val scalaOpts = envOrNone("SCALA_OPTS").toArray.flatMap(_.split(" ")).filter(_.nonEmpty)
val allArgs = scalaOpts ++ args
Expand Down Expand Up @@ -235,6 +245,16 @@ object MainGenericRunner {
++ List("-script", settings.targetScript)
++ settings.scriptArgs
scripting.Main.main(properArgs.toArray)
case ExecuteMode.Expression =>
val cp = settings.classPath match {
case Nil => ""
case list => list.mkString(classpathSeparator)
}
val cpArgs = if cp.isEmpty then Nil else List("-classpath", cp)
val properArgs = cpArgs ++ settings.residualArgs ++ settings.scalaArgs
val driver = StringDriver(properArgs.toArray, settings.targetExpression)
driver.compileAndRun(settings.classPath)

case ExecuteMode.Guess =>
if settings.modeShouldBePossibleRun then
run(settings.withExecuteMode(ExecuteMode.PossibleRun))
Expand Down
69 changes: 5 additions & 64 deletions compiler/src/dotty/tools/scripting/ScriptingDriver.scala
Expand Up @@ -2,19 +2,12 @@ package dotty.tools.scripting

import java.nio.file.{ Files, Paths, Path }
import java.io.File
import java.net.{ URL, URLClassLoader }
import java.lang.reflect.{ Modifier, Method }
import java.net.{ URLClassLoader }

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.dotc.Driver
import dotty.tools.dotc.core.Contexts, Contexts.{ Context, ctx }
import dotty.tools.io.{ PlainDirectory, Directory, ClassPath }
import dotty.tools.dotc.reporting.Reporter
import dotty.tools.dotc.config.Settings.Setting._

import sys.process._
import Util.*

class ScriptingDriver(compilerArgs: Array[String], scriptFile: File, scriptArgs: Array[String]) extends Driver:
def compileAndRun(pack:(Path, Seq[Path], String) => Boolean = null): Unit =
Expand All @@ -31,7 +24,7 @@ class ScriptingDriver(compilerArgs: Array[String], scriptFile: File, scriptArgs:
try
val classpath = s"${ctx.settings.classpath.value}${pathsep}${sys.props("java.class.path")}"
val classpathEntries: Seq[Path] = ClassPath.expandPath(classpath, expandStar=true).map { Paths.get(_) }
val (mainClass, mainMethod) = detectMainClassAndMethod(outDir, classpathEntries, scriptFile)
val (mainClass, mainMethod) = detectMainClassAndMethod(outDir, classpathEntries, scriptFile.toString)
val invokeMain: Boolean =
Option(pack) match
case Some(func) =>
Expand All @@ -48,58 +41,6 @@ class ScriptingDriver(compilerArgs: Array[String], scriptFile: File, scriptArgs:
case None =>
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 detectMainClassAndMethod(outDir: Path, classpathEntries: Seq[Path],
scriptFile: File): (String, Method) =

val classpathUrls = (classpathEntries :+ outDir).map { _.toUri.toURL }

val cl = URLClassLoader(classpathUrls.toArray)

def collectMainMethods(target: File, path: String): List[(String, 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((cls.getName, 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(s"No main methods detected in script ${scriptFile}")
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 detectMainClassAndMethod

def pathsep = sys.props("path.separator")

end ScriptingDriver

case class ScriptingException(msg: String) extends RuntimeException(msg)
45 changes: 45 additions & 0 deletions compiler/src/dotty/tools/scripting/StringDriver.scala
@@ -0,0 +1,45 @@
package dotty.tools.scripting

import java.nio.file.{ Files, Paths, Path }

import dotty.tools.dotc.Driver
import dotty.tools.dotc.core.Contexts, Contexts.{ Context, ctx }
import dotty.tools.io.{ PlainDirectory, Directory, ClassPath }
import Util.*

class StringDriver(compilerArgs: Array[String], scalaSource: String) extends Driver:
override def sourcesRequired: Boolean = false

def compileAndRun(classpath: List[String] = Nil): Unit =
val outDir = Files.createTempDirectory("scala3-expression")
outDir.toFile.deleteOnExit()

setup(compilerArgs, initCtx.fresh) match
case Some((toCompile, rootCtx)) =>
given Context = rootCtx.fresh.setSetting(rootCtx.settings.outputDir,
new PlainDirectory(Directory(outDir)))

val compiler = newCompiler
compiler.newRun.compileFromStrings(List(scalaSource))

val output = ctx.settings.outputDir.value
if ctx.reporter.hasErrors then
throw StringDriverException("Errors encountered during compilation")

try
val classpath = s"${ctx.settings.classpath.value}${pathsep}${sys.props("java.class.path")}"
val classpathEntries: Seq[Path] = ClassPath.expandPath(classpath, expandStar=true).map { Paths.get(_) }
sys.props("java.class.path") = classpathEntries.map(_.toString).mkString(pathsep)
val (mainClass, mainMethod) = detectMainClassAndMethod(outDir, classpathEntries, scalaSource)
mainMethod.invoke(null, Array.empty[String])
catch
case e: java.lang.reflect.InvocationTargetException =>
throw e.getCause
finally
deleteFile(outDir.toFile)
case None =>
end compileAndRun

end StringDriver

case class StringDriverException(msg: String) extends RuntimeException(msg)
60 changes: 60 additions & 0 deletions compiler/src/dotty/tools/scripting/Util.scala
@@ -0,0 +1,60 @@
package dotty.tools.scripting

import java.nio.file.{ Path }
import java.io.File
import java.net.{ URLClassLoader }
import java.lang.reflect.{ Modifier, Method }

object Util:

def deleteFile(target: File): Unit =
if target.isDirectory then
for member <- target.listFiles.toList
do deleteFile(member)
target.delete()
end deleteFile

def detectMainClassAndMethod(outDir: Path, classpathEntries: Seq[Path], srcFile: String): (String, Method) =
val classpathUrls = (classpathEntries :+ outDir).map { _.toUri.toURL }
val cl = URLClassLoader(classpathUrls.toArray)

def collectMainMethods(target: File, path: String): List[(String, 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((cls.getName, method)) else Nil
catch
case _: java.lang.NoSuchMethodException => Nil
else Nil
end collectMainMethods

val mains = for
file <- outDir.toFile.listFiles.toList
method <- collectMainMethods(file, "")
yield method

mains match
case Nil =>
throw StringDriverException(s"No main methods detected for [${srcFile}]")
case _ :: _ :: _ =>
throw StringDriverException(
s"internal error: Detected the following main methods:\n${mains.mkString("\n")}")
case m :: Nil => m
end match
end detectMainClassAndMethod

def pathsep = sys.props("path.separator")
Copy link
Contributor

Choose a reason for hiding this comment

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

There was a previous effort to avoid dependencies on packages that might become modules in 2.13, which did not happen. I'm conflicted about sys.props because OT1H it's just a Map interface for System.getProperty or ies, but OTOH it is not so trivial that it didn't harbor a couple of bugs.

I guess the convenience here is java.io.File.pathSeparator. No big deal, I just happened to have recently seen the Scala 2 commit about not using sys.props.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I wasn't aware of the discussion around packages that might become modules, but java.io.File.pathSeparator is a good alternative in any case.

It turns out that pathsep is defined identically in dotty.tools.scripting.Main, so the definition maybe belongs in dotty.tools.scripting.Util.


end Util

18 changes: 18 additions & 0 deletions compiler/test/dotty/tools/scripting/BashScriptsTests.scala
Expand Up @@ -2,6 +2,7 @@ package dotty
package tools
package scripting

import java.nio.file.Paths
import org.junit.{Test, AfterClass}
import org.junit.Assert.assertEquals

Expand Down Expand Up @@ -195,6 +196,8 @@ class BashScriptsTests:
val scriptBase = "sqlDateError"
val scriptFile = testFiles.find(_.getName == s"$scriptBase.sc").get
val testJar = testFile(s"$scriptBase.jar") // jar should not be created when scriptFile runs
val tj = Paths.get(testJar).toFile
if tj.isFile then tj.delete() // discard residual debris from previous test
printf("===> verify '-save' is cancelled by '-nosave' in script hashbang.`\n")
val (validTest, exitCode, stdout, stderr) = bashCommand(s"SCALA_OPTS=-save ${scriptFile.absPath}")
printf("stdout: %s\n", stdout.mkString("\n","\n",""))
Expand All @@ -209,3 +212,18 @@ class BashScriptsTests:
assert(valid, s"script ${scriptFile.absPath} reported unexpected value for java.sql.Date ${stdout.mkString("\n")}")
assert(!testJar.exists,s"unexpected, jar file [$testJar] was created")


/*
* verify -e println("yo!") works.
*/
@Test def verifyCommandLineExpression =
printf("===> verify -e <expression> is properly handled by `dist/bin/scala`\n")
val expected = "9"
val expression = s"println(3*3)"
val cmd = s"bin/scala -e $expression"
val (validTest, exitCode, stdout, stderr) = bashCommand(s"""bin/scala -e '$expression'""")
val result = stdout.filter(_.nonEmpty).mkString("")
printf("stdout: %s\n", result)
printf("stderr: %s\n", stderr.mkString("\n","\n",""))
if verifyValid(validTest) then
assert(result.contains(expected), s"expression [$expression] did not send [$expected] to stdout")
49 changes: 49 additions & 0 deletions compiler/test/dotty/tools/scripting/ExpressionTest.scala
@@ -0,0 +1,49 @@
package dotty
package tools
package scripting

import java.nio.file.Paths
import org.junit.{Test, AfterClass}
import org.junit.Assert.assertEquals

import vulpix.TestConfiguration

import ScriptTestEnv.*

/**
* +. test scala -e <expression>
*/
class ExpressionTest:
/*
* verify -e <expression> works.
*/
@Test def verifyCommandLineExpression =
printf("===> verify -e <expression> is properly handled by `dist/bin/scala`\n")
val expected = "9"
val expression = s"println(3*3)"
val result = getResult(expression)
assert(result.contains(expected), s"expression [$expression] did not send [$expected] to stdout")

@Test def verifyImports: Unit =
val expressionLines = List(
"import java.nio.file.Paths",
"""val cwd = Paths.get(""."")""",
"""println(cwd.toFile.listFiles.toList.filter(_.isDirectory).size)""",
)
val expression = expressionLines.mkString(";")
testExpression(expression){ result =>
result.matches("[0-9]+") && result.toInt > 0
}

def getResult(expression: String): String =
val cmd = s"bin/scala -e $expression"
val (_, _, stdout, stderr) = bashCommand(s"""bin/scala -e '$expression'""")
printf("stdout: %s\n", stdout.mkString("|"))
printf("stderr: %s\n", stderr.mkString("\n","\n",""))
stdout.filter(_.nonEmpty).mkString("")

def testExpression(expression: String)(check: (result: String) => Boolean) = {
val result = getResult(expression)
check(result)
}