[2.x] fix: Fixes script mode using Scala 3.x (#8900)

**Problem**
Scripts with scalaVersion 3.x in /*** */ and a shebang fail: -Xscript is
ignored by Scala 3, and the shebang line causes "Expected a toplevel
definition".

**Solution**
- Strip shebang when copying the script so the compiler never sees it.
- For Scala 3 only: do not add -Xscript; generate Main.scala wrapping the
  script body in object Main { def main(...) = { ... } }; use it as the
  single source and set run/mainClass to Main.
- For Scala 2: keep existing behavior (shebang stripped, -Xscript + script
  base name).
- Use Def.uncached and ScalaArtifacts.isScala3(scalaVersion.value) so
  embedded scalaVersion from /*** */ is respected.
This commit is contained in:
bitloi 2026-03-15 00:28:34 -03:00 committed by GitHub
parent 3f9bafc153
commit e12b3c9b9a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 169 additions and 6 deletions

View File

@ -184,6 +184,7 @@ final class ScriptMain extends xsbti.AppMain {
private[sbt] object ScriptMain {
private[sbt] def run(configuration: xsbti.AppConfiguration): xsbti.MainResult = {
import BasicCommandStrings.runEarly
Plugins.defaultRequires = sbt.plugins.JvmPlugin
val state = StandardMain.initialState(
xMain.dealiasBaseDirectory(configuration),
BuiltinCommands.ScriptCommands,

View File

@ -9,7 +9,7 @@
package sbt
package internal
import sbt.librarymanagement.Configurations
import sbt.librarymanagement.{ Configurations, ScalaArtifacts }
import sbt.util.Level
@ -26,6 +26,37 @@ import scala.annotation.tailrec
object Script {
final val Name = "script"
// When shebang is stripped, compiler error line numbers may be off by one for the original file;
// position mapping could be added in a future improvement (see sbt/sbt#6274).
/** If the first line is a shebang (#!), drop it so the compiler never sees it. */
private[internal] def stripShebang(lines: Seq[String]): Seq[String] =
if (lines.nonEmpty && lines.head.startsWith("#!")) lines.drop(1) else lines
/** Lines that are not inside any /*** ... */ block (i.e. the executable script body). */
private[internal] def scriptBodyLines(file: File): Seq[String] = {
val lines = IO.readLines(file).toIndexedSeq
// Block(offset, lines): offset = index of /*** line, lines = content between /*** and */ (excl. both).
// Exclude /*** (off), content (off+1..off+ls.size), and */ (off+1+ls.size).
val blockSet = blocks(file).flatMap {
case Block(off, ls) => (off until off + 2 + ls.size)
}.toSet
lines.indices.filterNot(blockSet).map(lines)
}
/** Write a Scala 3 compilable file that wraps the script body in object Main { def main(...) = { ... } }. */
private def writeWrappedScript(body: Seq[String], out: File): Unit = {
val indent = " "
val inner = body.map(line => indent + line).mkString("\n")
val content =
s"""object Main {
| def main(args: Array[String]): Unit = {
|$inner
| }
|}
|""".stripMargin
IO.write(out, content)
}
lazy val command =
Command.command(Name) { state =>
val scriptArg = state.remainingCommands.headOption map { _.commandLine } getOrElse sys.error(
@ -44,7 +75,11 @@ object Script {
else scriptArg.substring(0, dotIndex) + ".scala"
}
val script = new File(src, scalaFile)
IO.copyFile(scriptFile, script)
val linesWithoutShebang = stripShebang(IO.readLines(scriptFile))
IO.write(script, linesWithoutShebang.mkString("", "\n", "\n"))
val scriptMain = new File(src, "Main.scala")
writeWrappedScript(scriptBodyLines(script), scriptMain)
val (eval, structure) = Load.defaultLoad(state, base, state.log)
val session = Load.initialSession(structure, eval)
@ -55,12 +90,23 @@ object Script {
val embeddedSettings = blocks(script).flatMap { block =>
evaluate(eval(), vf, block.lines, currentUnit.imports, block.offset + 1)(currentLoader)
}
val scriptAsSource = (Compile / sources) := Def.uncached(script :: Nil)
val asScript =
scalacOptions ++= Def.uncached(Seq("-Xscript", script.getName.stripSuffix(".scala")))
val scriptBaseName = script.getName.stripSuffix(".scala")
val scriptAsSource = (Compile / sources) := Def.uncached {
if (ScalaArtifacts.isScala3(scalaVersion.value)) scriptMain :: Nil else script :: Nil
}
val asScript = scalacOptions := Def.uncached {
val extra =
if (ScalaArtifacts.isScala3(scalaVersion.value)) Nil
else Seq("-Xscript", scriptBaseName)
scalacOptions.value ++ extra
}
val scriptMainClass = (run / mainClass) := Def.uncached {
if (ScalaArtifacts.isScala3(scalaVersion.value)) Some("Main") else Some(scriptBaseName)
}
val scriptSettings = Seq(
asScript,
scriptAsSource,
scriptMainClass,
(Global / logLevel) := Level.Warn,
(Global / showSuccess) := false
)

View File

@ -0,0 +1,115 @@
/*
* sbt
* Copyright 2023, Scala center
* Copyright 2011 - 2022, Lightbend, Inc.
* Copyright 2008 - 2010, Mark Harrah
* Licensed under Apache License 2.0 (see LICENSE)
*/
package sbt.internal
import java.io.File
import verify.BasicTestSuite
import sbt.io.IO
object ScriptTest extends BasicTestSuite {
test("stripShebang returns same lines when first line does not start with #!") {
val lines = Seq("println(1)", "val x = 2")
val result = Script.stripShebang(lines)
assert(result == lines)
}
test("stripShebang drops first line when it is a shebang") {
val lines = Seq("#!/usr/bin/env sbt -Dsbt.main.class=sbt.ScriptMain", "println(1)")
val result = Script.stripShebang(lines)
assert(result == Seq("println(1)"))
}
test("stripShebang leaves empty list unchanged") {
val result = Script.stripShebang(Seq.empty)
assert(result.isEmpty)
}
test("stripShebang leaves single non-shebang line unchanged") {
val lines = Seq("println(42)")
val result = Script.stripShebang(lines)
assert(result == lines)
}
test("stripShebang drops only first line when it is #!") {
val lines = Seq("#!", "*/", "println(1)")
val result = Script.stripShebang(lines)
assert(result == Seq("*/", "println(1)"))
}
test("scriptBodyLines returns all lines when file has no blocks") {
val f = File.createTempFile("script", ".scala")
try {
IO.write(f, "println(1)\nval x = 2\n")
val result = Script.scriptBodyLines(f)
assert(result.contains("println(1)"))
assert(result.contains("val x = 2"))
assert(!result.exists(_.startsWith("/***")))
} finally f.delete()
}
test("scriptBodyLines excludes lines inside /*** */ block") {
val f = File.createTempFile("script", ".scala")
try {
IO.write(
f,
"""println("before")
|/***
|scalaVersion := "3.0.0"
|*/
|println("after")
|""".stripMargin
)
val result = Script.scriptBodyLines(f)
assert(result.contains("println(\"before\")"))
assert(result.contains("println(\"after\")"))
assert(!result.contains("scalaVersion := \"3.0.0\""))
assert(
!result.contains("*/"),
"closing */ must not appear in script body (would break wrapped Main.scala)"
)
} finally f.delete()
}
test("scriptBodyLines excludes block content when file has only a block") {
val f = File.createTempFile("script", ".scala")
try {
IO.write(
f,
"""/***
|scalaVersion := "3.0.0"
|*/
|""".stripMargin
)
val result = Script.scriptBodyLines(f)
assert(
!result.contains("scalaVersion := \"3.0.0\""),
s"block content must be excluded, got $result"
)
} finally f.delete()
}
test("blocks parses block containing settings") {
val f = File.createTempFile("script", ".scala")
try {
IO.write(
f,
"""line0
|/***
|scalaVersion := "3.0.0"
|*/
|line3
|""".stripMargin
)
val result = Script.blocks(f)
val settingBlock = result.find(_.lines.contains("scalaVersion := \"3.0.0\""))
assert(settingBlock.isDefined, s"expected a block with scalaVersion, got $result")
} finally f.delete()
}
}

View File

@ -53,5 +53,6 @@ object ArgParser:
.parse(parser, args, LauncherOptions())
.map: opts =>
val sbtNew = opts.residual.contains("new") || opts.residual.contains("init")
opts.copy(sbtNew = sbtNew)
val isScript = opts.residual.exists(_.startsWith("-Dsbt.main.class=sbt.ScriptMain"))
opts.copy(sbtNew = sbtNew, allowEmpty = opts.allowEmpty || isScript)
end ArgParser