diff --git a/modules/build/src/main/scala/scala/build/Build.scala b/modules/build/src/main/scala/scala/build/Build.scala index af990ef45f..04c587eed2 100644 --- a/modules/build/src/main/scala/scala/build/Build.scala +++ b/modules/build/src/main/scala/scala/build/Build.scala @@ -75,8 +75,9 @@ object Build { .replace(".", "_") .replace("/", ".") } - case Left(stdin @ "stdin") => Some(s"${stdin}_sc") - case _ => None + case Left(virtual) if virtual == "stdin" || virtual == "snippet" => + Some(s"${virtual}_sc") + case _ => None } val filteredMainClasses = mainClasses.filter(!scriptInferredMainClasses.contains(_)) diff --git a/modules/build/src/main/scala/scala/build/Inputs.scala b/modules/build/src/main/scala/scala/build/Inputs.scala index 845b878988..d33bfca9b4 100644 --- a/modules/build/src/main/scala/scala/build/Inputs.scala +++ b/modules/build/src/main/scala/scala/build/Inputs.scala @@ -186,7 +186,8 @@ object Inputs { } sealed abstract class VirtualSourceFile extends Virtual { - def isStdin: Boolean = source.contains("") + def isStdin: Boolean = source.contains("") + def isSnippet: Boolean = source.contains("") } sealed trait SingleFile extends OnDisk with SingleElement @@ -325,6 +326,32 @@ object Inputs { } } + def validateSnippets( + scriptSnippetOpt: Option[String] = None, + scalaSnippetOpt: Option[String] = None, + javaSnippetOpt: Option[String] = None + ): Seq[Either[String, Seq[Element]]] = { + def validateSnippet( + maybeExpression: Option[String], + f: Array[Byte] => Element + ): Option[Either[String, Seq[Element]]] = + maybeExpression.filter(_.nonEmpty).map(expression => + Right(Seq(f(expression.getBytes(StandardCharsets.UTF_8)))) + ) + + Seq( + validateSnippet( + scriptSnippetOpt, + content => VirtualScript(content, "snippet", os.sub / "snippet.sc") + ), + validateSnippet( + scalaSnippetOpt, + content => VirtualScalaFile(content, "-scala-file") + ), + validateSnippet(javaSnippetOpt, content => VirtualJavaFile(content, "-java-file")) + ).flatten + } + def validateArgs( args: Seq[String], cwd: os.Path, @@ -382,16 +409,22 @@ object Inputs { baseProjectName: String, download: String => Either[String, Array[Byte]], stdinOpt: => Option[Array[Byte]], + scriptSnippetOpt: Option[String], + scalaSnippetOpt: Option[String], + javaSnippetOpt: Option[String], acceptFds: Boolean, forcedWorkspace: Option[os.Path] ): Either[String, Inputs] = { val validatedArgs: Seq[Either[String, Seq[Element]]] = validateArgs(args, cwd, download, stdinOpt, acceptFds) - val invalid = validatedArgs.collect { + val validatedExpressions: Seq[Either[String, Seq[Element]]] = + validateSnippets(scriptSnippetOpt, scalaSnippetOpt, javaSnippetOpt) + val validatedArgsAndExprs = validatedArgs ++ validatedExpressions + val invalid = validatedArgsAndExprs.collect { case Left(msg) => msg } if (invalid.isEmpty) { - val validElems = validatedArgs.collect { + val validElems = validatedArgsAndExprs.collect { case Right(elem) => elem }.flatten assert(validElems.nonEmpty) @@ -410,10 +443,15 @@ object Inputs { defaultInputs: () => Option[Inputs] = () => None, download: String => Either[String, Array[Byte]] = _ => Left("URL not supported"), stdinOpt: => Option[Array[Byte]] = None, + scriptSnippetOpt: Option[String] = None, + scalaSnippetOpt: Option[String] = None, + javaSnippetOpt: Option[String] = None, acceptFds: Boolean = false, forcedWorkspace: Option[os.Path] = None ): Either[String, Inputs] = - if (args.isEmpty) + if ( + args.isEmpty && scriptSnippetOpt.isEmpty && scalaSnippetOpt.isEmpty && javaSnippetOpt.isEmpty + ) defaultInputs().toRight( "No inputs provided (expected files with .scala or .sc extensions, and / or directories)." ) @@ -425,6 +463,9 @@ object Inputs { baseProjectName, download, stdinOpt, + scriptSnippetOpt, + scalaSnippetOpt, + javaSnippetOpt, acceptFds, forcedWorkspace ) diff --git a/modules/build/src/main/scala/scala/build/preprocessing/JavaPreprocessor.scala b/modules/build/src/main/scala/scala/build/preprocessing/JavaPreprocessor.scala index 3ed048e448..ca81ab73d7 100644 --- a/modules/build/src/main/scala/scala/build/preprocessing/JavaPreprocessor.scala +++ b/modules/build/src/main/scala/scala/build/preprocessing/JavaPreprocessor.scala @@ -64,7 +64,7 @@ final case class JavaPreprocessor( case v: Inputs.VirtualJavaFile => val res = either { val relPath = - if (v.isStdin) { + if (v.isStdin || v.isSnippet) { val classNameOpt = value { (new JavaParserProxyMaker) .get( @@ -76,7 +76,7 @@ final case class JavaPreprocessor( } val fileName = classNameOpt .map(_ + ".java") - .getOrElse("stdin.java") + .getOrElse(if (v.isStdin) "stdin.java" else "java-snippet.java") os.sub / fileName } else v.subPath diff --git a/modules/build/src/main/scala/scala/build/preprocessing/ScalaPreprocessor.scala b/modules/build/src/main/scala/scala/build/preprocessing/ScalaPreprocessor.scala index 3d049b0c43..ea9d81f566 100644 --- a/modules/build/src/main/scala/scala/build/preprocessing/ScalaPreprocessor.scala +++ b/modules/build/src/main/scala/scala/build/preprocessing/ScalaPreprocessor.scala @@ -111,7 +111,11 @@ case object ScalaPreprocessor extends Preprocessor { case v: Inputs.VirtualScalaFile => val res = either { - val relPath = if (v.isStdin) os.sub / "stdin.scala" else v.subPath + val relPath = v match { + case v if v.isStdin => os.sub / "stdin.scala" + case v if v.isSnippet => os.sub / "scala-snippet.scala" + case v => v.subPath + } val content = new String(v.content, StandardCharsets.UTF_8) val (requirements, scopedRequirements, options, updatedContentOpt) = value( diff --git a/modules/cli-options/src/main/scala/scala/cli/commands/SharedOptions.scala b/modules/cli-options/src/main/scala/scala/cli/commands/SharedOptions.scala index 751a34757c..005fd3cd7d 100644 --- a/modules/cli-options/src/main/scala/scala/cli/commands/SharedOptions.scala +++ b/modules/cli-options/src/main/scala/scala/cli/commands/SharedOptions.scala @@ -45,7 +45,10 @@ final case class SharedOptions( @Group("Scala") @HelpMessage("Show help for scalac. This is an alias for --scalac-option -help") - scalacHelp: Boolean = false, + scalacHelp: Boolean = false, + + @Recurse + snippet: SnippetOptions = SnippetOptions(), @Group("Java") @HelpMessage("Add extra JARs in the class path") diff --git a/modules/cli-options/src/main/scala/scala/cli/commands/SnippetOptions.scala b/modules/cli-options/src/main/scala/scala/cli/commands/SnippetOptions.scala new file mode 100644 index 0000000000..aa2bac57fb --- /dev/null +++ b/modules/cli-options/src/main/scala/scala/cli/commands/SnippetOptions.scala @@ -0,0 +1,33 @@ +package scala.cli.commands + +import caseapp._ + +// format: off +final case class SnippetOptions( + @Group("Scala") + @HelpMessage("Allows to execute a passed string as a Scala script") + @Name("e") + @Name("executeScript") + @Name("executeScalaScript") + @Name("executeSc") + scriptSnippet: Option[String] = None, + + @Group("Scala") + @HelpMessage("Allows to execute a passed string as Scala code") + @Name("executeScala") + scalaSnippet: Option[String] = None, + + @Group("Java") + @HelpMessage("Allows to execute a passed string as Java code") + @Name("executeJava") + javaSnippet: Option[String] = None, +) +// format: on + +object SnippetOptions { + implicit lazy val parser: Parser[SnippetOptions] = Parser.derive + implicit lazy val help: Help[SnippetOptions] = Help.derive + // Parser.Aux for using ExpressionOptions with @Recurse in other options + implicit lazy val parserAux: Parser.Aux[SnippetOptions, parser.D] = parser + +} diff --git a/modules/cli/src/main/scala/scala/cli/commands/util/SharedOptionsUtil.scala b/modules/cli/src/main/scala/scala/cli/commands/util/SharedOptionsUtil.scala index 73fb01abe1..9cd88cb73c 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/util/SharedOptionsUtil.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/util/SharedOptionsUtil.scala @@ -268,6 +268,9 @@ object SharedOptionsUtil { defaultInputs = defaultInputs, download = downloadInputs, stdinOpt = readStdin(logger = logger), + scriptSnippetOpt = v.snippet.scriptSnippet, + scalaSnippetOpt = v.snippet.scalaSnippet, + javaSnippetOpt = v.snippet.javaSnippet, acceptFds = !Properties.isWin, forcedWorkspace = workspace.forcedWorkspaceOpt ) diff --git a/modules/integration/src/test/scala/scala/cli/integration/RunTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/RunTestDefinitions.scala index 20c6c82d28..488f96d88b 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/RunTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/RunTestDefinitions.scala @@ -803,6 +803,48 @@ abstract class RunTestDefinitions(val scalaVersionOpt: Option[String]) expect(output == expectedOutput) } } + test( + "snippets mixed with piped Scala code and existing sources allow for cross-references" + ) { + val hello = "Hello" + val comma = ", " + val world = "World" + val exclamation = "!" + val expectedOutput = hello + comma + world + exclamation + val scriptSnippet = s"def world = \"$world\"" + val scalaSnippet = "case class ScalaSnippetData(value: String)" + val javaSnippet = + s"public class JavaSnippet { public static String exclamation = \"$exclamation\"; }" + val pipedInput = s"def hello = \"$hello\"" + val inputs = + TestInputs(Seq(os.rel / "Main.scala" -> + s"""object Main extends App { + | val hello = stdin.hello + | val comma = ScalaSnippetData(value = "$comma").value + | val world = snippet.world + | val exclamation = JavaSnippet.exclamation + | println(hello + comma + world + exclamation) + |} + |""".stripMargin)) + inputs.fromRoot { root => + val output = + os.proc( + TestUtil.cli, + ".", + "_.sc", + "--script-snippet", + scriptSnippet, + "--scala-snippet", + scalaSnippet, + "--java-snippet", + javaSnippet, + extraOptions + ) + .call(cwd = root, stdin = pipedInput) + .out.text().trim + expect(output == expectedOutput) + } + } test("pick .scala main class over in-context scripts, including piped ones") { val inputs = TestInputs( Seq( @@ -1916,4 +1958,41 @@ abstract class RunTestDefinitions(val scalaVersionOpt: Option[String]) expect(mainClasses == Set(scalaFile1, scalaFile2, s"$scriptsDir.${scriptName}_sc")) } } + + test("correctly run a script snippet") { + emptyInputs.fromRoot { root => + val msg = "123456" // FIXME: change this to a a non-numeric string when Windows encoding is handled properly + val res = os.proc(TestUtil.cli, "-e", s"println($msg)", extraOptions).call(cwd = root) + expect(res.out.text().trim == msg) + } + } + + test("correctly run a scala snippet") { + emptyInputs.fromRoot { root => + val msg = "123456" // FIXME: change this to a a non-numeric string when Windows encoding is handled properly + val res = + os.proc( + TestUtil.cli, + "--scala-snippet", + s"object Hello extends App { println($msg) }", + extraOptions + ) + .call(cwd = root) + expect(res.out.text().trim == msg) + } + } + + test("correctly run a java snippet") { + emptyInputs.fromRoot { root => + val msg = "123456" // FIXME: change this to a a non-numeric string when Windows encoding is handled properly + val res = os.proc( + TestUtil.cli, + "--java-snippet", + s"public class Main { public static void main(String[] args) { System.out.println($msg); } }", + extraOptions + ) + .call(cwd = root) + expect(res.out.text().trim == msg) + } + } } diff --git a/website/docs/guides/snippets.md b/website/docs/guides/snippets.md new file mode 100644 index 0000000000..fecfb18d7a --- /dev/null +++ b/website/docs/guides/snippets.md @@ -0,0 +1,217 @@ +--- +title: Snippets +sidebar_position: 23 +--- + +import {ChainedSnippets} from "../../src/components/MarkdownComponents.js"; + +# Snippets + +Instead of passing paths to your sources, you can also pass the code itself with the appropriate option. + + + +```bash +scala-cli --scala-snippet '@main def hello() = println("Hello")' +``` + +```text +Hello +``` + + + +## Examples + +- scripts + + + +```bash +scala-cli -e 'println("Hello")' +``` + +```text +Hello +``` + + + +- Scala code + + + +```bash +scala-cli --scala-snippet '@main def hello() = println("Hello")' +``` + +```text +Hello +``` + + + +- Java code + + + +```bash +scala-cli --java-snippet 'class Hello { public static void main(String args[]) { System.out.println("Hello"); } }' +``` + +```text +Hello +``` + + + +- a mix of Scala, Java and scripts + + + +```bash +scala-cli --scala-snippet '@main def hello() = println(s"${JavaSnippet.hello} ${snippet.world}")' --java-snippet 'public class JavaSnippet { public static String hello = "Hello"; }' --script-snippet 'def world = "world"' +``` + +```text +Hello world +``` + + + +## Snippets and other kinds of inputs + +It is also possible to mix snippets with on-disk sources. + + + +```bash ignore +cat Main.scala +``` + +```scala +object Main extends App { + val snippetData = SnippetData() + println(snippetData.value) +} +``` + +```bash ignore +scala-cli Main.scala --scala-snippet 'case class SnippetData(value: String = "Hello")' +``` + +```text +Hello +``` + + + +Or even with piped ones, why not. + + + +```bash +echo 'println(SnippetData().value)' || scala-cli _.sc --scala-snippet 'case class SnippetData(value: String = "Hello")' +``` + +```text +Hello +``` + + + +Nothing stops you from mixing everything all at once, really. + + + +```bash ignore +cat Main.scala +``` + +```scala +object Main extends App { + val scalaSnippetString = ScalaSnippet().value + val javaSnippetString = JavaSnippet.data + val scriptSnippetString = snippet.script + val pipedInputString = stdin.piped + val ondiskScriptString = ondisk.script + println(s"Output: $scalaSnippetString $javaSnippetString $scriptSnippetString $pipedInputString") +} +``` + +```bash ignore +cat ondisk.sc +``` + +```scala +def script = "on-disk-script" +``` + +```bash ignore +echo 'def piped = "piped-script"'|scala-cli-prototype . _.sc --scala-snippet 'case class ScalaSnippet(value: String = "scala-snippet")' --java-snippet 'public class JavaSnippet { public static String data = "java-snippet"; }' --script-snippet 'def script = "script-snippet"' +``` + +```text +Output: scala-snippet java-snippet script-snippet piped-script +``` + + + +## Referring to code from a snippet + +When referring to code coming from a script snippet passed with `--script-snippet` (or `-e`), you use its wrapper +name: `snippet` + + + +```bash +scala-cli --scala-snippet '@main def main() = println(snippet.hello)' --script-snippet 'def hello: String = "Hello"' +``` + +```text +Hello +``` + + + +This is similar to how you refer to code from piped scripts through their wrapper name (`stdin`), more on which can be +found in [the scripts guide](scripts.md). + +In fact, you can refer to both kinds of scripts at one time, just keep in mind that you need to pick which script is to +actually be run with the `--main-class` option when multiple scripts are present on the classpath (and no non-script +main class was passed). + + + +```bash ignore +cat ondisk.sc +``` + +```scala title=ondisk.sc +println(s"${stdin.hello} ${snippet.world}") +``` + +```bash ignore +echo 'def hello = "Hello"' | scala-cli _.sc ondisk.sc -e 'def world = "world"' --main-class ondisk_sc +``` + +```text +Hello world +``` + + + +When in doubt on what main classes are available on the classpath, you can always refer to the output +of `--list-main-classes` + + + +```bash ignore +echo 'def hello = "Hello"' | scala-cli _.sc ondisk.sc -e 'def world = "world"' --list-main-classes +``` + +```text +ondisk_sc snippet_sc stdin_sc +``` + + diff --git a/website/docs/reference/cli-options.md b/website/docs/reference/cli-options.md index 2f0bea87e7..4bc373495f 100644 --- a/website/docs/reference/cli-options.md +++ b/website/docs/reference/cli-options.md @@ -1606,6 +1606,45 @@ Generate SemanticDBs #### `--strict-bloop-json-check` +## Snippet options + +Available in commands: +- [`bsp`](./commands.md#bsp) +- [`compile`](./commands.md#compile) +- [`doc`](./commands.md#doc) +- [`export`](./commands.md#export) +- [`fmt` / `format` / `scalafmt`](./commands.md#fmt) +- [`browse` / `metabrowse`](./commands.md#browse) +- [`package`](./commands.md#package) +- [`publish`](./commands.md#publish) +- [`publish local`](./commands.md#publish-local) +- [`console` / `repl`](./commands.md#console) +- [`run`](./commands.md#run) +- [`setup-ide`](./commands.md#setup-ide) +- [`shebang`](./commands.md#shebang) +- [`test`](./commands.md#test) + + + + +#### `--script-snippet` + +Aliases: `-e`, `--execute-script`, `--execute-scala-script`, `--execute-sc` + +Allows to execute a passed string as a Scala script + +#### `--scala-snippet` + +Aliases: `--execute-scala` + +Allows to execute a passed string as Scala code + +#### `--java-snippet` + +Aliases: `--execute-java` + +Allows to execute a passed string as Java code + ## Test options Available in commands: diff --git a/website/docs/reference/commands.md b/website/docs/reference/commands.md index 2fd46bce74..fc5aad8d90 100644 --- a/website/docs/reference/commands.md +++ b/website/docs/reference/commands.md @@ -40,6 +40,7 @@ Accepts options: - [Scala Native](./cli-options.md#scala-native-options) - [scalac](./cli-options.md#scalac-options) - [shared](./cli-options.md#shared-options) +- [snippet](./cli-options.md#snippet-options) - [verbosity](./cli-options.md#verbosity-options) - [watch](./cli-options.md#watch-options) - [workspace](./cli-options.md#workspace-options) @@ -61,6 +62,7 @@ Accepts options: - [Scala Native](./cli-options.md#scala-native-options) - [scalac](./cli-options.md#scalac-options) - [shared](./cli-options.md#shared-options) +- [snippet](./cli-options.md#snippet-options) - [verbosity](./cli-options.md#verbosity-options) - [workspace](./cli-options.md#workspace-options) @@ -90,6 +92,7 @@ Accepts options: - [Scala Native](./cli-options.md#scala-native-options) - [scalac](./cli-options.md#scalac-options) - [shared](./cli-options.md#shared-options) +- [snippet](./cli-options.md#snippet-options) - [verbosity](./cli-options.md#verbosity-options) - [workspace](./cli-options.md#workspace-options) @@ -114,6 +117,7 @@ Accepts options: - [Scala Native](./cli-options.md#scala-native-options) - [scalac](./cli-options.md#scalac-options) - [shared](./cli-options.md#shared-options) +- [snippet](./cli-options.md#snippet-options) - [verbosity](./cli-options.md#verbosity-options) - [workspace](./cli-options.md#workspace-options) @@ -156,6 +160,7 @@ Accepts options: - [Scala Native](./cli-options.md#scala-native-options) - [scalac](./cli-options.md#scalac-options) - [shared](./cli-options.md#shared-options) +- [snippet](./cli-options.md#snippet-options) - [verbosity](./cli-options.md#verbosity-options) - [watch](./cli-options.md#watch-options) - [workspace](./cli-options.md#workspace-options) @@ -180,6 +185,7 @@ Accepts options: - [Scala Native](./cli-options.md#scala-native-options) - [scalac](./cli-options.md#scalac-options) - [shared](./cli-options.md#shared-options) +- [snippet](./cli-options.md#snippet-options) - [verbosity](./cli-options.md#verbosity-options) - [watch](./cli-options.md#watch-options) - [workspace](./cli-options.md#workspace-options) @@ -203,6 +209,7 @@ Accepts options: - [Scala Native](./cli-options.md#scala-native-options) - [scalac](./cli-options.md#scalac-options) - [shared](./cli-options.md#shared-options) +- [snippet](./cli-options.md#snippet-options) - [verbosity](./cli-options.md#verbosity-options) - [watch](./cli-options.md#watch-options) - [workspace](./cli-options.md#workspace-options) @@ -225,6 +232,7 @@ Accepts options: - [Scala Native](./cli-options.md#scala-native-options) - [scalac](./cli-options.md#scalac-options) - [shared](./cli-options.md#shared-options) +- [snippet](./cli-options.md#snippet-options) - [verbosity](./cli-options.md#verbosity-options) - [watch](./cli-options.md#watch-options) - [workspace](./cli-options.md#workspace-options) @@ -255,6 +263,7 @@ Accepts options: - [Scala Native](./cli-options.md#scala-native-options) - [scalac](./cli-options.md#scalac-options) - [shared](./cli-options.md#shared-options) +- [snippet](./cli-options.md#snippet-options) - [verbosity](./cli-options.md#verbosity-options) - [watch](./cli-options.md#watch-options) - [workspace](./cli-options.md#workspace-options) @@ -299,6 +308,7 @@ Accepts options: - [scalac](./cli-options.md#scalac-options) - [setup IDE](./cli-options.md#setup-ide-options) - [shared](./cli-options.md#shared-options) +- [snippet](./cli-options.md#snippet-options) - [verbosity](./cli-options.md#verbosity-options) - [workspace](./cli-options.md#workspace-options) @@ -346,6 +356,7 @@ Accepts options: - [Scala Native](./cli-options.md#scala-native-options) - [scalac](./cli-options.md#scalac-options) - [shared](./cli-options.md#shared-options) +- [snippet](./cli-options.md#snippet-options) - [verbosity](./cli-options.md#verbosity-options) - [watch](./cli-options.md#watch-options) - [workspace](./cli-options.md#workspace-options) @@ -368,6 +379,7 @@ Accepts options: - [Scala Native](./cli-options.md#scala-native-options) - [scalac](./cli-options.md#scalac-options) - [shared](./cli-options.md#shared-options) +- [snippet](./cli-options.md#snippet-options) - [test](./cli-options.md#test-options) - [verbosity](./cli-options.md#verbosity-options) - [watch](./cli-options.md#watch-options) @@ -453,6 +465,7 @@ Accepts options: - [Scala Native](./cli-options.md#scala-native-options) - [scalac](./cli-options.md#scalac-options) - [shared](./cli-options.md#shared-options) +- [snippet](./cli-options.md#snippet-options) - [verbosity](./cli-options.md#verbosity-options) - [workspace](./cli-options.md#workspace-options) @@ -508,6 +521,7 @@ Accepts options: - [Scala Native](./cli-options.md#scala-native-options) - [scalac](./cli-options.md#scalac-options) - [shared](./cli-options.md#shared-options) +- [snippet](./cli-options.md#snippet-options) - [verbosity](./cli-options.md#verbosity-options) - [workspace](./cli-options.md#workspace-options)