Skip to content

Commit 7ad6d02

Browse files
Merge pull request #11379 from philwalk/minimal-scripting-support
Minimal scripting support
2 parents 5efc249 + be1072f commit 7ad6d02

File tree

11 files changed

+419
-34
lines changed

11 files changed

+419
-34
lines changed

compiler/src/dotty/tools/dotc/core/Contexts.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,7 @@ object Contexts {
274274
/** Sourcefile corresponding to given abstract file, memoized */
275275
def getSource(file: AbstractFile, codec: => Codec = Codec(settings.encoding.value)) = {
276276
util.Stats.record("Context.getSource")
277-
base.sources.getOrElseUpdate(file, new SourceFile(file, codec))
277+
base.sources.getOrElseUpdate(file, SourceFile(file, codec))
278278
}
279279

280280
/** SourceFile with given path name, memoized */

compiler/src/dotty/tools/dotc/util/SourceFile.scala

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,34 @@ object ScriptSourceFile {
2222
@sharable private val headerPattern = Pattern.compile("""^(::)?!#.*(\r|\n|\r\n)""", Pattern.MULTILINE)
2323
private val headerStarts = List("#!", "::#!")
2424

25+
/** Return true if has a script header */
26+
def hasScriptHeader(content: Array[Char]): Boolean =
27+
headerStarts.exists(content.startsWith(_))
28+
2529
def apply(file: AbstractFile, content: Array[Char]): SourceFile = {
2630
/** Length of the script header from the given content, if there is one.
27-
* The header begins with "#!" or "::#!" and ends with a line starting
28-
* with "!#" or "::!#".
31+
* The header begins with "#!" or "::#!" and is either a single line,
32+
* or it ends with a line starting with "!#" or "::!#", if present.
2933
*/
3034
val headerLength =
3135
if (headerStarts exists (content startsWith _)) {
3236
val matcher = headerPattern matcher content.mkString
3337
if (matcher.find) matcher.end
34-
else throw new IOException("script file does not close its header with !# or ::!#")
38+
else content.indexOf('\n') // end of first line
3539
}
3640
else 0
37-
new SourceFile(file, content drop headerLength) {
41+
42+
// overwrite hash-bang lines with all spaces to preserve line numbers
43+
val hashBangLines = content.take(headerLength).mkString.split("\\r?\\n")
44+
if hashBangLines.nonEmpty then
45+
for i <- 0 until headerLength do
46+
content(i) match {
47+
case '\r' | '\n' =>
48+
case _ =>
49+
content(i) = ' '
50+
}
51+
52+
new SourceFile(file, content) {
3853
override val underlying = new SourceFile(this.file, this.content)
3954
}
4055
}
@@ -245,6 +260,25 @@ object SourceFile {
245260
else
246261
sourcePath.toString
247262
}
263+
264+
/** Return true if file is a script:
265+
* if filename extension is not .scala and has a script header.
266+
*/
267+
def isScript(file: AbstractFile, content: Array[Char]): Boolean =
268+
ScriptSourceFile.hasScriptHeader(content)
269+
270+
def apply(file: AbstractFile, codec: Codec): SourceFile =
271+
// see note above re: Files.exists is remarkably slow
272+
val chars =
273+
try
274+
new String(file.toByteArray, codec.charSet).toCharArray
275+
catch
276+
case _: java.nio.file.NoSuchFileException => Array[Char]()
277+
278+
if isScript(file, chars) then
279+
ScriptSourceFile(file, chars)
280+
else
281+
new SourceFile(file, chars)
248282
}
249283

250284
@sharable object NoSource extends SourceFile(NoAbstractFile, Array[Char]()) {
Lines changed: 91 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,108 @@
11
package dotty.tools.scripting
22

33
import java.io.File
4+
import java.nio.file.Path
5+
import dotty.tools.dotc.config.Properties.isWin
46

57
/** Main entry point to the Scripting execution engine */
68
object Main:
79
/** All arguments before -script <target_script> are compiler arguments.
810
All arguments afterwards are script arguments.*/
9-
def distinguishArgs(args: Array[String]): (Array[String], File, Array[String]) =
10-
val (compilerArgs, rest) = args.splitAt(args.indexOf("-script"))
11+
private def distinguishArgs(args: Array[String]): (Array[String], File, Array[String], Boolean, Boolean) =
12+
val (leftArgs, rest) = args.splitAt(args.indexOf("-script"))
13+
assert(rest.size >= 2,s"internal error: rest == Array(${rest.mkString(",")})")
14+
1115
val file = File(rest(1))
1216
val scriptArgs = rest.drop(2)
13-
(compilerArgs, file, scriptArgs)
17+
var saveJar = false
18+
var invokeFlag = true // by default, script main method is invoked
19+
val compilerArgs = leftArgs.filter {
20+
case "-save" | "-savecompiled" =>
21+
saveJar = true
22+
false
23+
case "-compile-only" =>
24+
invokeFlag = false // no call to script main method
25+
false
26+
case _ =>
27+
true
28+
}
29+
(compilerArgs, file, scriptArgs, saveJar, invokeFlag)
1430
end distinguishArgs
1531

1632
def main(args: Array[String]): Unit =
17-
val (compilerArgs, scriptFile, scriptArgs) = distinguishArgs(args)
18-
try ScriptingDriver(compilerArgs, scriptFile, scriptArgs).compileAndRun()
33+
val (compilerArgs, scriptFile, scriptArgs, saveJar, invokeFlag) = distinguishArgs(args)
34+
val driver = ScriptingDriver(compilerArgs, scriptFile, scriptArgs)
35+
try driver.compileAndRun { (outDir:Path, classpath:String, mainClass: String) =>
36+
if saveJar then
37+
// write a standalone jar to the script parent directory
38+
writeJarfile(outDir, scriptFile, scriptArgs, classpath, mainClass)
39+
invokeFlag
40+
}
1941
catch
2042
case ScriptingException(msg) =>
2143
println(s"Error: $msg")
2244
sys.exit(1)
45+
46+
case e: java.lang.reflect.InvocationTargetException =>
47+
throw e.getCause
48+
49+
private def writeJarfile(outDir: Path, scriptFile: File, scriptArgs:Array[String],
50+
classpath:String, mainClassName: String): Unit =
51+
52+
val javaClasspath = sys.props("java.class.path")
53+
val runtimeClasspath = s"${classpath}$pathsep$javaClasspath"
54+
55+
val jarTargetDir: Path = Option(scriptFile.toPath.getParent) match {
56+
case None => sys.error(s"no parent directory for script file [$scriptFile]")
57+
case Some(parent) => parent
58+
}
59+
60+
def scriptBasename = scriptFile.getName.takeWhile(_!='.')
61+
val jarPath = s"$jarTargetDir/$scriptBasename.jar"
62+
63+
val cpPaths = runtimeClasspath.split(pathsep).map(_.absPath)
64+
65+
import java.util.jar.Attributes.Name
66+
val cpString:String = cpPaths.distinct.mkString(" ")
67+
val manifestAttributes:Seq[(Name, String)] = Seq(
68+
(Name.MANIFEST_VERSION, "1.0"),
69+
(Name.MAIN_CLASS, mainClassName),
70+
(Name.CLASS_PATH, cpString),
71+
)
72+
import dotty.tools.io.{Jar, Directory}
73+
val jar = new Jar(jarPath)
74+
val writer = jar.jarWriter(manifestAttributes:_*)
75+
try
76+
writer.writeAllFrom(Directory(outDir))
77+
finally
78+
writer.close()
79+
end writeJarfile
80+
81+
def pathsep = sys.props("path.separator")
82+
83+
84+
extension(file: File){
85+
def norm: String = file.toString.norm
86+
}
87+
88+
extension(path: String) {
89+
// Normalize path separator, convert relative path to absolute
90+
def norm: String =
91+
path.replace('\\', '/') match {
92+
case s if s.secondChar == ":" => s.drop(2)
93+
case s if s.startsWith("./") => s.drop(2)
94+
case s => s
95+
}
96+
97+
// convert to absolute path relative to cwd.
98+
def absPath: String = norm match
99+
case str if str.isAbsolute => norm
100+
case _ => s"/${sys.props("user.dir").norm}/$norm"
101+
102+
def absFile: File = File(path.absPath)
103+
104+
// Treat norm paths with a leading '/' as absolute.
105+
// Windows java.io.File#isAbsolute treats them as relative.
106+
def isAbsolute = path.norm.startsWith("/") || (isWin && path.secondChar == ":")
107+
def secondChar: String = path.take(2).drop(1).mkString("")
108+
}

compiler/src/dotty/tools/scripting/ScriptingDriver.scala

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import dotty.tools.dotc.config.Settings.Setting._
1717
import sys.process._
1818

1919
class ScriptingDriver(compilerArgs: Array[String], scriptFile: File, scriptArgs: Array[String]) extends Driver:
20-
def compileAndRun(): Unit =
20+
def compileAndRun(pack:(Path, String, String) => Boolean = null): Unit =
2121
val outDir = Files.createTempDirectory("scala3-scripting")
2222
val (toCompile, rootCtx) = setup(compilerArgs :+ scriptFile.getAbsolutePath, initCtx.fresh)
2323
given Context = rootCtx.fresh.setSetting(rootCtx.settings.outputDir,
@@ -26,7 +26,16 @@ class ScriptingDriver(compilerArgs: Array[String], scriptFile: File, scriptArgs:
2626
if doCompile(newCompiler, toCompile).hasErrors then
2727
throw ScriptingException("Errors encountered during compilation")
2828

29-
try detectMainMethod(outDir, ctx.settings.classpath.value).invoke(null, scriptArgs)
29+
try
30+
val (mainClass, mainMethod) = detectMainClassAndMethod(outDir, ctx.settings.classpath.value, scriptFile)
31+
val invokeMain: Boolean =
32+
Option(pack) match
33+
case Some(func) =>
34+
func(outDir, ctx.settings.classpath.value, mainClass)
35+
case None =>
36+
true
37+
end match
38+
if invokeMain then mainMethod.invoke(null, scriptArgs)
3039
catch
3140
case e: java.lang.reflect.InvocationTargetException =>
3241
throw e.getCause
@@ -41,12 +50,13 @@ class ScriptingDriver(compilerArgs: Array[String], scriptFile: File, scriptArgs:
4150
target.delete()
4251
end deleteFile
4352

44-
private def detectMainMethod(outDir: Path, classpath: String): Method =
53+
private def detectMainClassAndMethod(outDir: Path, classpath: String,
54+
scriptFile: File): (String, Method) =
4555
val outDirURL = outDir.toUri.toURL
46-
val classpathUrls = classpath.split(":").map(File(_).toURI.toURL)
56+
val classpathUrls = classpath.split(pathsep).map(File(_).toURI.toURL)
4757
val cl = URLClassLoader(classpathUrls :+ outDirURL)
4858

49-
def collectMainMethods(target: File, path: String): List[Method] =
59+
def collectMainMethods(target: File, path: String): List[(String, Method)] =
5060
val nameWithoutExtension = target.getName.takeWhile(_ != '.')
5161
val targetPath =
5262
if path.nonEmpty then s"${path}.${nameWithoutExtension}"
@@ -61,7 +71,7 @@ class ScriptingDriver(compilerArgs: Array[String], scriptFile: File, scriptArgs:
6171
val cls = cl.loadClass(targetPath)
6272
try
6373
val method = cls.getMethod("main", classOf[Array[String]])
64-
if Modifier.isStatic(method.getModifiers) then List(method) else Nil
74+
if Modifier.isStatic(method.getModifiers) then List((cls.getName, method)) else Nil
6575
catch
6676
case _: java.lang.NoSuchMethodException => Nil
6777
else Nil
@@ -74,13 +84,16 @@ class ScriptingDriver(compilerArgs: Array[String], scriptFile: File, scriptArgs:
7484

7585
candidates match
7686
case Nil =>
77-
throw ScriptingException("No main methods detected in your script")
87+
throw ScriptingException(s"No main methods detected in script ${scriptFile}")
7888
case _ :: _ :: _ =>
7989
throw ScriptingException("A script must contain only one main method. " +
8090
s"Detected the following main methods:\n${candidates.mkString("\n")}")
8191
case m :: Nil => m
8292
end match
83-
end detectMainMethod
93+
end detectMainClassAndMethod
94+
95+
def pathsep = sys.props("path.separator")
96+
8497
end ScriptingDriver
8598

8699
case class ScriptingException(msg: String) extends RuntimeException(msg)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
#!/usr/bin/env scala
2+
# comment
3+
STUFF=nada
4+
!#
5+
// everything above this point should be ignored by the compiler
6+
def main(args: Array[String]): Unit =
7+
args.zipWithIndex.foreach { case (arg,i) => printf("arg %d: [%s]\n",i,arg) }
8+
System.err.printf("mainClassFromStack: %s\n",mainFromStack)
9+
assert(mainFromStack.contains("hashBang"),s"fromStack[$mainFromStack]")
10+
11+
lazy val mainFromStack:String = {
12+
val result = new java.io.StringWriter()
13+
new RuntimeException("stack").printStackTrace(new java.io.PrintWriter(result))
14+
val stack = result.toString.split("[\r\n]+").toList
15+
if verbose then for( s <- stack ){ System.err.printf("[%s]\n",s) }
16+
stack.filter { str => str.contains(".main(") }.map {
17+
// derive main class name from stack when main object is NOT declared in source
18+
_.replaceAll("[.].*","").
19+
replaceAll("\\s+at\\s+","")
20+
}.distinct.take(1).mkString("")
21+
}
22+
23+
lazy val verbose = Option(System.getenv("DOTC_VERBOSE")) match
24+
case None => false
25+
case _ => true
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
#!/usr/bin/env scala
2+
export STUFF=nada
3+
#lots of other stuff that isn't valid scala
4+
!#
5+
// everything above this point should be ignored by the compiler
6+
object Zoo {
7+
def main(args: Array[String]): Unit =
8+
args.zipWithIndex.foreach { case (arg,i) => printf("arg %d: [%s]\n",i,arg) }
9+
printf("mainClassFromStack: %s\n",mainClassFromStack)
10+
assert(mainClassFromStack == "Zoo",s"fromStack[$mainClassFromStack]")
11+
12+
lazy val mainClassFromStack:String = {
13+
val result = new java.io.StringWriter()
14+
new RuntimeException("stack").printStackTrace(new java.io.PrintWriter(result))
15+
val stack = result.toString.split("[\r\n]+").toList
16+
if verbose then for( s <- stack ){ System.err.printf("[%s]\n",s) }
17+
val shortStack = stack.filter { str => str.contains(".main(") && ! str.contains("$") }.map {
18+
// derive main class name from stack when main object is declared in source
19+
_.replaceAll("[.].*","").
20+
replaceAll("\\s+at\\s+","")
21+
}
22+
// for( s <- shortStack ){ System.err.printf("[%s]\n",s) }
23+
shortStack.take(1).mkString("|")
24+
}
25+
26+
lazy val verbose = Option(System.getenv("DOTC_VERBOSE")) match
27+
case None => false
28+
case _ => true
29+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import java.nio.file.Paths
2+
3+
object ScriptParent {
4+
def main(args: Array[String]): Unit = {
5+
args.zipWithIndex.foreach { case (arg,i) => printf("arg %d: [%s]\n",i,arg) }
6+
val scriptName = Option(sys.props("script.path")) match {
7+
case None =>
8+
printf("no script.path property\n")
9+
case Some(script) =>
10+
val p = Paths.get(script).toAbsolutePath.toFile.getParent
11+
printf("parentDir: [%s]\n",p)
12+
}
13+
}
14+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#!/usr/bin/env scala
2+
3+
def main(args: Array[String]): Unit =
4+
args.zipWithIndex.foreach { case (arg,i) => printf("arg %d: [%s]\n",i,arg) }
5+
val path = Option(sys.props("script.path")) match {
6+
case None => printf("no script.path property is defined\n")
7+
case Some(path) =>
8+
printf("script.path: %s\n",path)
9+
assert(path.endsWith("scriptPath.sc"),s"actual path [$path]")
10+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
#!/usr/bin/env scala
2+
3+
import java.io.File
4+
5+
// create an empty file
6+
def main(args: Array[String]): Unit =
7+
val file = File("touchedFile.out")
8+
file.createNewFile();

0 commit comments

Comments
 (0)