Skip to content

Commit 6e33db7

Browse files
committed
Add support of running script files with no extension using shebang subcommand
1 parent 81c2e3e commit 6e33db7

File tree

8 files changed

+89
-18
lines changed

8 files changed

+89
-18
lines changed

modules/build/src/main/scala/scala/build/input/Inputs.scala

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import scala.build.internal.Constants
1313
import scala.build.internal.zip.WrappedZipInputStream
1414
import scala.build.options.Scope
1515
import scala.build.preprocessing.ScopePath
16+
import scala.build.preprocessing.SheBang.isShebangScript
1617
import scala.util.Properties
1718
import scala.util.matching.Regex
1819

@@ -261,7 +262,8 @@ object Inputs {
261262
download: String => Either[String, Array[Byte]],
262263
stdinOpt: => Option[Array[Byte]],
263264
acceptFds: Boolean,
264-
enableMarkdown: Boolean
265+
enableMarkdown: Boolean,
266+
isRunWithShebang: Boolean
265267
): Seq[Either[String, Seq[Element]]] = args.zipWithIndex.map {
266268
case (arg, idx) =>
267269
lazy val path = os.Path(arg, cwd)
@@ -297,10 +299,21 @@ object Inputs {
297299
else if os.isDir(path) then Right(Seq(Directory(path)))
298300
else if acceptFds && arg.startsWith("/dev/fd/") then
299301
Right(Seq(VirtualScript(content, arg, os.sub / s"input-${idx + 1}.sc")))
302+
else if isRunWithShebang && os.exists(path) then
303+
if isShebangScript(String(content)) then Right(Seq(Script(dir, subPath)))
304+
else
305+
Left(s"""$arg does not contain shebang header
306+
|possible fixes:
307+
| Add '#!/usr/bin/env scala-cli shebang' to the top of the file
308+
| Add extension to the file's name e.q. '.sc'
309+
|""".stripMargin)
300310
else {
301311
val msg =
302-
if (os.exists(path))
303-
s"$arg: unrecognized source type (expected .scala or .sc extension, or a directory)"
312+
if os.exists(path) then
313+
if isShebangScript(String(content)) then
314+
s"$arg scripts with no file extension should be run with 'scala-cli shebang'"
315+
else
316+
s"$arg: unrecognized source type (expected .scala or .sc extension, or a directory)"
304317
else s"$arg: not found"
305318
Left(msg)
306319
}
@@ -320,10 +333,11 @@ object Inputs {
320333
forcedWorkspace: Option[os.Path],
321334
enableMarkdown: Boolean,
322335
allowRestrictedFeatures: Boolean,
323-
extraClasspathWasPassed: Boolean
336+
extraClasspathWasPassed: Boolean,
337+
isRunWithShebang: Boolean
324338
): Either[BuildException, Inputs] = {
325339
val validatedArgs: Seq[Either[String, Seq[Element]]] =
326-
validateArgs(args, cwd, download, stdinOpt, acceptFds, enableMarkdown)
340+
validateArgs(args, cwd, download, stdinOpt, acceptFds, enableMarkdown, isRunWithShebang)
327341
val validatedSnippets: Seq[Either[String, Seq[Element]]] =
328342
validateSnippets(scriptSnippetList, scalaSnippetList, javaSnippetList, markdownSnippetList)
329343
val validatedArgsAndSnippets = validatedArgs ++ validatedSnippets
@@ -364,7 +378,8 @@ object Inputs {
364378
forcedWorkspace: Option[os.Path] = None,
365379
enableMarkdown: Boolean = false,
366380
allowRestrictedFeatures: Boolean,
367-
extraClasspathWasPassed: Boolean
381+
extraClasspathWasPassed: Boolean,
382+
isRunWithShebang: Boolean
368383
): Either[BuildException, Inputs] =
369384
if (
370385
args.isEmpty && scriptSnippetList.isEmpty && scalaSnippetList.isEmpty && javaSnippetList.isEmpty &&
@@ -388,7 +403,8 @@ object Inputs {
388403
forcedWorkspace,
389404
enableMarkdown,
390405
allowRestrictedFeatures,
391-
extraClasspathWasPassed
406+
extraClasspathWasPassed,
407+
isRunWithShebang
392408
)
393409

394410
def default(): Option[Inputs] = None

modules/build/src/main/scala/scala/build/preprocessing/SheBang.scala

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import scala.util.matching.Regex
55
object SheBang {
66
private val sheBangRegex: Regex = s"""(^(#!.*(\\r\\n?|\\n)?)+(\\s*!#.*)?)""".r
77

8+
def isShebangScript(content: String): Boolean = sheBangRegex.unanchored.matches(content)
9+
810
def ignoreSheBangLines(content: String): (String, Boolean) =
911
if (content.startsWith("#!")) {
1012
val regexMatch = sheBangRegex.findFirstMatchIn(content)

modules/build/src/test/scala/scala/build/tests/TestInputs.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ final case class TestInputs(
4444
tmpDir,
4545
forcedWorkspace = forcedWorkspaceOpt.map(_.resolveFrom(tmpDir)),
4646
allowRestrictedFeatures = true,
47-
extraClasspathWasPassed = false
47+
extraClasspathWasPassed = false,
48+
isRunWithShebang = false
4849
)
4950
res match {
5051
case Left(err) => throw new Exception(err)

modules/cli/src/main/scala/scala/cli/commands/clean/Clean.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ object Clean extends ScalaCommand[CleanOptions] {
2121
defaultInputs = () => Inputs.default(),
2222
forcedWorkspace = options.workspace.forcedWorkspaceOpt,
2323
allowRestrictedFeatures = ScalaCli.allowRestrictedFeatures,
24-
extraClasspathWasPassed = false
24+
extraClasspathWasPassed = false,
25+
isRunWithShebang = false
2526
) match {
2627
case Left(message) =>
2728
System.err.println(message)

modules/cli/src/main/scala/scala/cli/commands/run/Run.scala

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,16 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers {
106106
inputArgs: Seq[String],
107107
programArgs: Seq[String],
108108
defaultInputs: () => Option[Inputs],
109-
logger: Logger
109+
logger: Logger,
110+
isRunWithShebang: Boolean = false
110111
): Unit = {
111112
val initialBuildOptions = buildOptionsOrExit(options)
112113

113-
val inputs = options.shared.inputs(inputArgs, defaultInputs = defaultInputs).orExit(logger)
114+
val inputs = options.shared.inputs(
115+
inputArgs,
116+
defaultInputs = defaultInputs,
117+
isRunWithShebang
118+
).orExit(logger)
114119
CurrentParams.workspaceOpt = Some(inputs.workspace)
115120
val threads = BuildThreads.create()
116121

modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ final case class SharedOptions(
177177
@Recurse
178178
input: SharedInputOptions = SharedInputOptions(),
179179
@Recurse
180-
helpGroups: HelpGroupOptions = HelpGroupOptions(),
180+
helpGroups: HelpGroupOptions = HelpGroupOptions(),
181181

182182
@Hidden
183183
strictBloopJsonCheck: Option[Boolean] = None,
@@ -512,7 +512,8 @@ final case class SharedOptions(
512512

513513
def inputs(
514514
args: Seq[String],
515-
defaultInputs: () => Option[Inputs] = () => Inputs.default()
515+
defaultInputs: () => Option[Inputs] = () => Inputs.default(),
516+
isRunWithShebang: Boolean = false
516517
): Either[BuildException, Inputs] =
517518
SharedOptions.inputs(
518519
args,
@@ -529,7 +530,8 @@ final case class SharedOptions(
529530
javaSnippetList = allJavaSnippets,
530531
markdownSnippetList = allMarkdownSnippets,
531532
enableMarkdown = markdown.enableMarkdown,
532-
extraClasspathWasPassed = extraJarsAndClassPath.nonEmpty
533+
extraClasspathWasPassed = extraJarsAndClassPath.nonEmpty,
534+
isRunWithShebang = isRunWithShebang
533535
)
534536

535537
def allScriptSnippets: List[String] = snippet.scriptSnippet ++ snippet.executeScript
@@ -544,7 +546,8 @@ final case class SharedOptions(
544546
SharedOptions.downloadInputs(coursierCache),
545547
SharedOptions.readStdin(logger = logger),
546548
!Properties.isWin,
547-
enableMarkdown = true
549+
enableMarkdown = true,
550+
isRunWithShebang = false
548551
)
549552

550553
def strictBloopJsonCheckOrDefault: Boolean =
@@ -582,7 +585,8 @@ object SharedOptions {
582585
javaSnippetList: List[String],
583586
markdownSnippetList: List[String],
584587
enableMarkdown: Boolean = false,
585-
extraClasspathWasPassed: Boolean = false
588+
extraClasspathWasPassed: Boolean = false,
589+
isRunWithShebang: Boolean = false
586590
): Either[BuildException, Inputs] = {
587591
val resourceInputs = resourceDirs
588592
.map(os.Path(_, Os.pwd))
@@ -606,7 +610,8 @@ object SharedOptions {
606610
forcedWorkspace = forcedWorkspaceOpt,
607611
enableMarkdown = enableMarkdown,
608612
allowRestrictedFeatures = ScalaCli.allowRestrictedFeatures,
609-
extraClasspathWasPassed = extraClasspathWasPassed
613+
extraClasspathWasPassed = extraClasspathWasPassed,
614+
isRunWithShebang
610615
)
611616
maybeInputs.map { inputs =>
612617
val forbiddenDirs =

modules/cli/src/main/scala/scala/cli/commands/shebang/Shebang.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ object Shebang extends ScalaCommand[ShebangOptions] {
2323
args.remaining.headOption.toSeq,
2424
args.remaining.drop(1),
2525
() => None,
26-
logger
26+
logger,
27+
isRunWithShebang = true
2728
)
2829
}

modules/integration/src/test/scala/scala/cli/integration/RunScriptTestDefinitions.scala

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,4 +326,44 @@ trait RunScriptTestDefinitions { _: RunTestDefinitions =>
326326
expect(p.out.trim() == "List(1, 2, 3, -v)")
327327
}
328328
}
329+
330+
test("script file with shebang header and no extension run with scala-cli shebang") {
331+
val inputs = TestInputs(
332+
os.rel / "script-with-shebang" ->
333+
s"""|#!/usr/bin/env -S ${TestUtil.cli.mkString(" ")} shebang -S 2.13
334+
|//> using scala "$actualScalaVersion"
335+
|println(args.toList)""".stripMargin
336+
)
337+
inputs.fromRoot { root =>
338+
val output = if (!Properties.isWin) {
339+
os.perms.set(root / "script-with-shebang", os.PermSet.fromString("rwx------"))
340+
os.proc("./script-with-shebang", "1", "2", "3", "-v").call(cwd = root).out.trim()
341+
}
342+
else
343+
os.proc(TestUtil.cli, "shebang", "script-with-shebang", "1", "2", "3", "-v")
344+
.call(cwd = root).out.trim()
345+
expect(output == "List(1, 2, 3, -v)")
346+
}
347+
}
348+
349+
test("script file with NO shebang header and no extension run with scala-cli shebang") {
350+
val inputs = TestInputs(
351+
os.rel / "script-no-shebang" ->
352+
s"""//> using scala "$actualScalaVersion"
353+
|println(args.toList)""".stripMargin
354+
)
355+
inputs.fromRoot { root =>
356+
val output = if (!Properties.isWin) {
357+
os.perms.set(root / "script-no-shebang", os.PermSet.fromString("rwx------"))
358+
os.proc(TestUtil.cli, "shebang", "script-no-shebang", "1", "2", "3", "-v")
359+
.call(cwd = root, check = false, stderr = os.Pipe).err.trim()
360+
}
361+
else
362+
os.proc(TestUtil.cli, "shebang", "script-no-shebang", "1", "2", "3", "-v")
363+
.call(cwd = root, check = false, stderr = os.Pipe).err.trim()
364+
expect(output.contains(
365+
"does not contain shebang header"
366+
))
367+
}
368+
}
329369
}

0 commit comments

Comments
 (0)