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 support for running script files with no extension using shebang subcommand #1802

Merged
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions modules/build/src/main/scala/scala/build/input/Inputs.scala
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import scala.build.internal.Constants
import scala.build.internal.zip.WrappedZipInputStream
import scala.build.options.Scope
import scala.build.preprocessing.ScopePath
import scala.build.preprocessing.SheBang.isShebangScript
import scala.util.Properties
import scala.util.matching.Regex

Expand Down Expand Up @@ -261,7 +262,8 @@ object Inputs {
download: String => Either[String, Array[Byte]],
stdinOpt: => Option[Array[Byte]],
acceptFds: Boolean,
enableMarkdown: Boolean
enableMarkdown: Boolean,
isRunWithShebang: Boolean
): Seq[Either[String, Seq[Element]]] = args.zipWithIndex.map {
case (arg, idx) =>
lazy val path = os.Path(arg, cwd)
Expand Down Expand Up @@ -297,10 +299,21 @@ object Inputs {
else if os.isDir(path) then Right(Seq(Directory(path)))
else if acceptFds && arg.startsWith("/dev/fd/") then
Right(Seq(VirtualScript(content, arg, os.sub / s"input-${idx + 1}.sc")))
else if isRunWithShebang && os.exists(path) then
if isShebangScript(String(content)) then Right(Seq(Script(dir, subPath)))
else
Left(s"""$arg does not contain shebang header
|possible fixes:
| Add '#!/usr/bin/env scala-cli shebang' to the top of the file
| Add extension to the file's name e.q. '.sc'
|""".stripMargin)
else {
val msg =
if (os.exists(path))
s"$arg: unrecognized source type (expected .scala or .sc extension, or a directory)"
if os.exists(path) then
if isShebangScript(String(content)) then
s"$arg scripts with no file extension should be run with 'scala-cli shebang'"
else
s"$arg: unrecognized source type (expected .scala or .sc extension, or a directory)"
else s"$arg: not found"
Left(msg)
}
Expand All @@ -320,10 +333,11 @@ object Inputs {
forcedWorkspace: Option[os.Path],
enableMarkdown: Boolean,
allowRestrictedFeatures: Boolean,
extraClasspathWasPassed: Boolean
extraClasspathWasPassed: Boolean,
isRunWithShebang: Boolean
): Either[BuildException, Inputs] = {
val validatedArgs: Seq[Either[String, Seq[Element]]] =
validateArgs(args, cwd, download, stdinOpt, acceptFds, enableMarkdown)
validateArgs(args, cwd, download, stdinOpt, acceptFds, enableMarkdown, isRunWithShebang)
val validatedSnippets: Seq[Either[String, Seq[Element]]] =
validateSnippets(scriptSnippetList, scalaSnippetList, javaSnippetList, markdownSnippetList)
val validatedArgsAndSnippets = validatedArgs ++ validatedSnippets
Expand Down Expand Up @@ -364,7 +378,8 @@ object Inputs {
forcedWorkspace: Option[os.Path] = None,
enableMarkdown: Boolean = false,
allowRestrictedFeatures: Boolean,
extraClasspathWasPassed: Boolean
extraClasspathWasPassed: Boolean,
isRunWithShebang: Boolean
): Either[BuildException, Inputs] =
if (
args.isEmpty && scriptSnippetList.isEmpty && scalaSnippetList.isEmpty && javaSnippetList.isEmpty &&
Expand All @@ -388,7 +403,8 @@ object Inputs {
forcedWorkspace,
enableMarkdown,
allowRestrictedFeatures,
extraClasspathWasPassed
extraClasspathWasPassed,
isRunWithShebang
)

def default(): Option[Inputs] = None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import scala.util.matching.Regex
object SheBang {
private val sheBangRegex: Regex = s"""(^(#!.*(\\r\\n?|\\n)?)+(\\s*!#.*)?)""".r

def isShebangScript(content: String): Boolean = sheBangRegex.unanchored.matches(content)

def ignoreSheBangLines(content: String): (String, Boolean) =
if (content.startsWith("#!")) {
val regexMatch = sheBangRegex.findFirstMatchIn(content)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ final case class TestInputs(
tmpDir,
forcedWorkspace = forcedWorkspaceOpt.map(_.resolveFrom(tmpDir)),
allowRestrictedFeatures = true,
extraClasspathWasPassed = false
extraClasspathWasPassed = false,
isRunWithShebang = false
)
res match {
case Left(err) => throw new Exception(err)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ object Clean extends ScalaCommand[CleanOptions] {
defaultInputs = () => Inputs.default(),
forcedWorkspace = options.workspace.forcedWorkspaceOpt,
allowRestrictedFeatures = ScalaCli.allowRestrictedFeatures,
extraClasspathWasPassed = false
extraClasspathWasPassed = false,
isRunWithShebang = false
) match {
case Left(message) =>
System.err.println(message)
Expand Down
9 changes: 7 additions & 2 deletions modules/cli/src/main/scala/scala/cli/commands/run/Run.scala
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,16 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers {
inputArgs: Seq[String],
programArgs: Seq[String],
defaultInputs: () => Option[Inputs],
logger: Logger
logger: Logger,
isRunWithShebang: Boolean = false
): Unit = {
val initialBuildOptions = buildOptionsOrExit(options)

val inputs = options.shared.inputs(inputArgs, defaultInputs = defaultInputs).orExit(logger)
val inputs = options.shared.inputs(
inputArgs,
defaultInputs = defaultInputs,
isRunWithShebang
).orExit(logger)
CurrentParams.workspaceOpt = Some(inputs.workspace)
val threads = BuildThreads.create()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ final case class SharedOptions(
@Recurse
input: SharedInputOptions = SharedInputOptions(),
@Recurse
helpGroups: HelpGroupOptions = HelpGroupOptions(),
helpGroups: HelpGroupOptions = HelpGroupOptions(),

@Hidden
strictBloopJsonCheck: Option[Boolean] = None,
Expand Down Expand Up @@ -512,7 +512,8 @@ final case class SharedOptions(

def inputs(
args: Seq[String],
defaultInputs: () => Option[Inputs] = () => Inputs.default()
defaultInputs: () => Option[Inputs] = () => Inputs.default(),
isRunWithShebang: Boolean = false
): Either[BuildException, Inputs] =
SharedOptions.inputs(
args,
Expand All @@ -529,7 +530,8 @@ final case class SharedOptions(
javaSnippetList = allJavaSnippets,
markdownSnippetList = allMarkdownSnippets,
enableMarkdown = markdown.enableMarkdown,
extraClasspathWasPassed = extraJarsAndClassPath.nonEmpty
extraClasspathWasPassed = extraJarsAndClassPath.nonEmpty,
isRunWithShebang = isRunWithShebang
)

def allScriptSnippets: List[String] = snippet.scriptSnippet ++ snippet.executeScript
Expand All @@ -544,7 +546,8 @@ final case class SharedOptions(
SharedOptions.downloadInputs(coursierCache),
SharedOptions.readStdin(logger = logger),
!Properties.isWin,
enableMarkdown = true
enableMarkdown = true,
isRunWithShebang = false
)

def strictBloopJsonCheckOrDefault: Boolean =
Expand Down Expand Up @@ -582,7 +585,8 @@ object SharedOptions {
javaSnippetList: List[String],
markdownSnippetList: List[String],
enableMarkdown: Boolean = false,
extraClasspathWasPassed: Boolean = false
extraClasspathWasPassed: Boolean = false,
isRunWithShebang: Boolean = false
): Either[BuildException, Inputs] = {
val resourceInputs = resourceDirs
.map(os.Path(_, Os.pwd))
Expand All @@ -606,7 +610,8 @@ object SharedOptions {
forcedWorkspace = forcedWorkspaceOpt,
enableMarkdown = enableMarkdown,
allowRestrictedFeatures = ScalaCli.allowRestrictedFeatures,
extraClasspathWasPassed = extraClasspathWasPassed
extraClasspathWasPassed = extraClasspathWasPassed,
isRunWithShebang
)
maybeInputs.map { inputs =>
val forbiddenDirs =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ object Shebang extends ScalaCommand[ShebangOptions] {
args.remaining.headOption.toSeq,
args.remaining.drop(1),
() => None,
logger
logger,
isRunWithShebang = true
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -326,4 +326,44 @@ trait RunScriptTestDefinitions { _: RunTestDefinitions =>
expect(p.out.trim() == "List(1, 2, 3, -v)")
}
}

test("script file with shebang header and no extension run with scala-cli shebang") {
MaciejG604 marked this conversation as resolved.
Show resolved Hide resolved
val inputs = TestInputs(
os.rel / "script-with-shebang" ->
s"""|#!/usr/bin/env -S ${TestUtil.cli.mkString(" ")} shebang -S 2.13
|//> using scala "$actualScalaVersion"
|println(args.toList)""".stripMargin
)
inputs.fromRoot { root =>
val output = if (!Properties.isWin) {
os.perms.set(root / "script-with-shebang", os.PermSet.fromString("rwx------"))
os.proc("./script-with-shebang", "1", "2", "3", "-v").call(cwd = root).out.trim()
}
else
os.proc(TestUtil.cli, "shebang", "script-with-shebang", "1", "2", "3", "-v")
.call(cwd = root).out.trim()
expect(output == "List(1, 2, 3, -v)")
}
}

test("script file with NO shebang header and no extension run with scala-cli shebang") {
val inputs = TestInputs(
os.rel / "script-no-shebang" ->
MaciejG604 marked this conversation as resolved.
Show resolved Hide resolved
s"""//> using scala "$actualScalaVersion"
|println(args.toList)""".stripMargin
)
inputs.fromRoot { root =>
val output = if (!Properties.isWin) {
os.perms.set(root / "script-no-shebang", os.PermSet.fromString("rwx------"))
os.proc(TestUtil.cli, "shebang", "script-no-shebang", "1", "2", "3", "-v")
.call(cwd = root, check = false, stderr = os.Pipe).err.trim()
}
else
os.proc(TestUtil.cli, "shebang", "script-no-shebang", "1", "2", "3", "-v")
.call(cwd = root, check = false, stderr = os.Pipe).err.trim()
expect(output.contains(
"does not contain shebang header"
))
}
}
}
4 changes: 4 additions & 0 deletions website/docs/commands/shebang.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ sidebar_position: 30
This command is equivalent to `run`, but it changes the way Scala CLI parses options (used to configure the tool) and
inputs (the sources of your project) in order to be compatible with `shebang` scripts.

The command `shebang` also allows script files to be executed even if they have no file extension,
provided they start with the [`shebang` header](../guides/shebang.md#shebang-script-headers).
Note that those files are always run as scripts even though they may contain e.g. valid `.scala` program.

Normally, inputs and `scala-cli` options can be mixed. Program arguments (to be passed to your app) have to be specified
after `--` (double dash) separator.

Expand Down
4 changes: 4 additions & 0 deletions website/docs/guides/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ It is also possible to set `scala-cli` command-line options in the shebang line,
#!/usr/bin/env -S scala-cli shebang --scala-version 2.13
```

The command `shebang` also allows script files to be executed even if they have no file extension,
provided they start with the [`shebang` header](../guides/shebang.md#shebang-script-headers).
Note that those files are always run as scripts even though they may contain e.g. valid `.scala` program.

### Arguments

You may also pass arguments to your script, and they are referenced with the special `args` variable:
Expand Down
63 changes: 61 additions & 2 deletions website/docs/guides/shebang.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ println(args.size)
println(args.headOption)
```

And it works almost correctly.
And it works correctly:

<ChainedSnippets>

Expand All @@ -40,7 +40,7 @@ None

</ChainedSnippets>

And it also works.
And it also works:

<ChainedSnippets>

Expand Down Expand Up @@ -173,4 +173,63 @@ world: not found
world: not found
-->

</ChainedSnippets>

### Script files' extensions

When running the `shebang` subcommand, script files don't need the `.sc` extension,
but they are then REQUIRED to start with a shebang line:

```scala title=hello-with-shebang
#!/usr/bin/env -S scala-cli shebang -S 3

println(args.size)
println(args.headOption)
```

<ChainedSnippets>

```bash
chmod +x hello-with-shebang
./hello-with-shebang Hello World
```

```text
2
Some(Hello)
```
<!-- Expected:
2
Some(Hello)
-->

</ChainedSnippets>

```scala title=hello-no-shebang
println(args.size)
println(args.headOption)
```

<ChainedSnippets>

```bash run-fail
chmod +x hello-no-shebang
scala-cli shebang hello-no-shebang Hello World
```

```text
[error] hello-no-shebang does not contain shebang header
possible fixes:
Add '#!/usr/bin/env scala-cli shebang' to the top of the file
Add extension to the file's name e.q. '.sc'
```
<!-- Expected:
[error] hello-no-shebang does not contain shebang header
possible fixes:
Add '#!/usr/bin/env scala-cli shebang' to the top of the file
Add extension to the file's name e.q. '.sc'
-->

Note that files with no extensions are always run as scripts even though they may contain e.g. valid `.scala` program.

</ChainedSnippets>