Skip to content

Commit 4453a13

Browse files
authored
feat: allow to pass scalac options using files (#3012)
* feat: allow to pass args using files * read arg files to process options * correctly split args from file * fix test * generate ref docs
1 parent 5744e9c commit 4453a13

File tree

11 files changed

+269
-16
lines changed

11 files changed

+269
-16
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ target/
1313

1414
# ignore vim backup files
1515
*.sw[op]
16+
.DS_Store
1617

modules/cli/src/main/scala/scala/cli/commands/ScalaCommand.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ abstract class ScalaCommand[T <: HasGlobalOptions](implicit myParser: Parser[T],
172172
import scala.cli.commands.shared.ScalacOptions.YScriptRunnerOption
173173
val logger = options.global.logging.logger
174174
sharedOptions(options).foreach { so =>
175-
val scalacOpts = so.scalac.scalacOption.toScalacOptShadowingSeq
175+
val scalacOpts = so.scalacOptions.toScalacOptShadowingSeq
176176
if scalacOpts.keys.contains(ScalacOpt(YScriptRunnerOption)) then
177177
logger.message(
178178
LegacyScalaOptions.yScriptRunnerWarning(scalacOpts.getOption(YScriptRunnerOption))
@@ -188,7 +188,7 @@ abstract class ScalaCommand[T <: HasGlobalOptions](implicit myParser: Parser[T],
188188
def maybePrintSimpleScalacOutput(options: T, buildOptions: BuildOptions): Unit =
189189
for {
190190
shared <- sharedOptions(options)
191-
scalacOptions = shared.scalac.scalacOption
191+
scalacOptions = shared.scalacOptions
192192
updatedScalacOptions = scalacOptions.withScalacExtraOptions(shared.scalacExtra)
193193
if updatedScalacOptions.exists(ScalacOptions.ScalacPrintOptions)
194194
logger = shared.logger
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package scala.cli.commands.shared
2+
3+
import scala.annotation.tailrec
4+
import scala.collection.mutable.ListBuffer
5+
6+
object ArgSplitter {
7+
def splitToArgs(input: String) = {
8+
val iter = input.iterator
9+
val accumulator = new ListBuffer[String]
10+
11+
@tailrec
12+
def takeWhile(
13+
test: Char => Boolean,
14+
acc: List[Char] = Nil,
15+
prevWasEscape: Boolean = false
16+
): String =
17+
iter.nextOption() match
18+
case Some(c) if !prevWasEscape && test(c) => acc.reverse.mkString
19+
case None => acc.reverse.mkString
20+
case Some('\\') =>
21+
takeWhile(test, '\\' :: acc, prevWasEscape = true)
22+
case Some(c) =>
23+
takeWhile(test, c :: acc, prevWasEscape = false)
24+
25+
while (iter.hasNext)
26+
iter.next() match
27+
case c if c.isSpaceChar || c == '\n' || c == '\r' =>
28+
case c @ ('\'' | '"') => accumulator += s"$c${takeWhile(_ == c)}$c"
29+
case c =>
30+
accumulator += s"$c${takeWhile(c => c.isSpaceChar || c == '\n' || c == '\r')}"
31+
32+
accumulator.result()
33+
}
34+
35+
}

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

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ package scala.cli.commands.shared
22

33
import caseapp.*
44
import caseapp.core.Scala3Helpers.*
5-
import caseapp.core.parser.{Argument, NilParser, StandardArgument}
5+
import caseapp.core.parser.{Argument, ConsParser, NilParser, StandardArgument}
66
import caseapp.core.util.Formatter
77
import caseapp.core.{Arg, Error}
88
import com.github.plokhotnyuk.jsoniter_scala.core.*
@@ -12,14 +12,16 @@ import scala.cli.commands.tags
1212

1313
// format: off
1414
final case class ScalacOptions(
15+
@Recurse
16+
argsFiles: List[ArgFileOption] = Nil,
1517
@Group(HelpGroup.Scala.toString)
1618
@HelpMessage("Add a scalac option")
1719
@ValueDescription("option")
1820
@Name("O")
1921
@Name("scala-opt")
2022
@Name("scala-option")
2123
@Tag(tags.must)
22-
scalacOption: List[String] = Nil
24+
scalacOption: List[String] = Nil,
2325
)
2426
// format: on
2527

@@ -72,8 +74,9 @@ object ScalacOptions {
7274

7375
/** This includes all the scalac options which are redirected to native Scala CLI options. */
7476
val ScalaCliRedirectedOptions = Set(
75-
"-classpath", // redirected to --extra-jars
76-
"-d" // redirected to --compilation-output
77+
"-classpath",
78+
"-cp", // redirected to --extra-jars
79+
"-d" // redirected to --compilation-output
7780
)
7881
val ScalacDeprecatedOptions: Set[String] = Set(
7982
YScriptRunnerOption // old 'scala' runner specific, no longer supported
@@ -115,11 +118,46 @@ object ScalacOptions {
115118
}
116119

117120
implicit lazy val parser: Parser[ScalacOptions] = {
118-
val baseParser =
119-
scalacOptionsArgument ::
120-
NilParser
121-
baseParser.to[ScalacOptions]
121+
val baseParser = scalacOptionsArgument :: NilParser
122+
implicit val p = ArgFileOption.parser
123+
baseParser.addAll[List[ArgFileOption]].to[ScalacOptions]
122124
}
125+
123126
implicit lazy val help: Help[ScalacOptions] = Help.derive
124127
implicit lazy val jsonCodec: JsonValueCodec[ScalacOptions] = JsonCodecMaker.make
125128
}
129+
130+
case class ArgFileOption(file: String) extends AnyVal
131+
132+
object ArgFileOption {
133+
val arg = Arg(
134+
name = Name("args-file"),
135+
valueDescription = Some(ValueDescription("@arguments-file")),
136+
helpMessage = Some(HelpMessage("File with scalac options.")),
137+
group = Some(Group("Scala")),
138+
origin = Some("ScalacOptions")
139+
)
140+
implicit lazy val parser: Parser[List[ArgFileOption]] = new Parser[List[ArgFileOption]] {
141+
type D = List[ArgFileOption] *: EmptyTuple
142+
143+
override def withDefaultOrigin(origin: String): Parser[List[ArgFileOption]] = this
144+
145+
override def init: D = Nil *: EmptyTuple
146+
147+
override def step(args: List[String], index: Int, d: D, nameFormatter: Formatter[Name])
148+
: Either[(core.Error, Arg, List[String]), Option[(D, Arg, List[String])]] =
149+
args match
150+
case head :: rest if head.startsWith("@") =>
151+
val newD = (ArgFileOption(head.stripPrefix("@")) :: d._1) *: EmptyTuple
152+
Right(Some(newD, arg, rest))
153+
case _ => Right(None)
154+
155+
override def get(
156+
d: D,
157+
nameFormatter: Formatter[Name]
158+
): Either[core.Error, List[ArgFileOption]] = Right(d.head)
159+
160+
override def args: Seq[Arg] = Seq(arg)
161+
162+
}
163+
}

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

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package scala.cli.commands.shared
22

33
import bloop.rifle.BloopRifleConfig
44
import caseapp.*
5+
import caseapp.core.Arg
56
import caseapp.core.help.Help
7+
import caseapp.core.util.Formatter
68
import com.github.plokhotnyuk.jsoniter_scala.core.*
79
import com.github.plokhotnyuk.jsoniter_scala.macros.*
810
import coursier.cache.FileCache
@@ -290,13 +292,20 @@ final case class SharedOptions(
290292
)
291293
}
292294

295+
lazy val scalacOptionsFromFiles: List[String] =
296+
scalac.argsFiles.flatMap(argFile =>
297+
ArgSplitter.splitToArgs(os.read(os.Path(argFile.file, os.pwd)))
298+
)
299+
300+
def scalacOptions: List[String] = scalac.scalacOption ++ scalacOptionsFromFiles
301+
293302
def buildOptions(
294303
enableJmh: Boolean = false,
295304
jmhVersion: Option[String] = None,
296305
ignoreErrors: Boolean = false
297306
): Either[BuildException, bo.BuildOptions] = either {
298-
val releaseOpt = scalac.scalacOption.getScalacOption("-release")
299-
val targetOpt = scalac.scalacOption.getScalacPrefixOption("-target")
307+
val releaseOpt = scalacOptions.getScalacOption("-release")
308+
val targetOpt = scalacOptions.getScalacPrefixOption("-target")
300309
jvm.jvm -> (releaseOpt.toSeq ++ targetOpt) match {
301310
case (Some(j), compilerTargets) if compilerTargets.exists(_ != j) =>
302311
val compilerTargetsString = compilerTargets.distinct.mkString(", ")
@@ -380,8 +389,7 @@ final case class SharedOptions(
380389
semanticDbTargetRoot = semanticDbOptions.semanticDbTargetRoot.map(os.Path(_, os.pwd)),
381390
semanticDbSourceRoot = semanticDbOptions.semanticDbSourceRoot.map(os.Path(_, os.pwd))
382391
),
383-
scalacOptions = scalac
384-
.scalacOption
392+
scalacOptions = scalacOptions
385393
.withScalacExtraOptions(scalacExtra)
386394
.toScalacOptShadowingSeq
387395
.filterNonRedirected
@@ -465,7 +473,9 @@ final case class SharedOptions(
465473
}
466474

467475
def extraJarsAndClassPath: List[os.Path] =
468-
(extraJars ++ scalac.scalacOption.getScalacOption("-classpath"))
476+
(extraJars ++ scalacOptions.getScalacOption("-classpath") ++ scalacOptions.getScalacOption(
477+
"-cp"
478+
))
469479
.extractedClassPath
470480

471481
def extraClasspathWasPassed: Boolean = extraJarsAndClassPath.exists(!_.hasSourceJarSuffix)
@@ -635,6 +645,7 @@ final case class SharedOptions(
635645
}
636646

637647
object SharedOptions {
648+
import ArgFileOption.parser
638649
implicit lazy val parser: Parser[SharedOptions] = Parser.derive
639650
implicit lazy val help: Help[SharedOptions] = Help.derive
640651
implicit lazy val jsonCodec: JsonValueCodec[SharedOptions] = JsonCodecMaker.make

modules/cli/src/main/scala/scala/cli/commands/util/BuildCommandHelpers.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ trait BuildCommandHelpers { self: ScalaCommand[_] =>
2222
*/
2323
def copyOutput(sharedOptions: SharedOptions): Unit =
2424
sharedOptions.compilationOutput.filter(_.nonEmpty)
25-
.orElse(sharedOptions.scalac.scalacOption.getScalacOption("-d"))
25+
.orElse(sharedOptions.scalacOptions.getScalacOption("-d"))
2626
.filter(_.nonEmpty)
2727
.map(os.Path(_, Os.pwd)).foreach { output =>
2828
os.copy(
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package cli.tests
2+
3+
import scala.cli.commands.shared.ArgSplitter
4+
5+
class ArgSplitterTest extends munit.FunSuite {
6+
7+
test("test scalac options are split correctly") {
8+
val args = List(
9+
List("-arg", "-other-arg"),
10+
List("-yet-another-arg", "dir/path\\ with\\ space", "'another arg with space'"),
11+
List("\"yet another arg with space\"")
12+
)
13+
val input = args.map(_.mkString(" ", " ", "")).mkString(" ", "\n", "")
14+
assertEquals(ArgSplitter.splitToArgs(input), args.flatten)
15+
}
16+
17+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package scala.cli.integration
2+
3+
import scala.util.Properties
4+
5+
class ArgsFileTests extends ScalaCliSuite {
6+
7+
val forOption = List(true, false)
8+
9+
for (useServer <- forOption) test(
10+
s"pass scalac options using arguments file ${if (useServer) "with bloop" else "without bloop"}"
11+
) {
12+
val fileName = "Simple.sc"
13+
val serverArgs = if (useServer) Nil else List("--server=false")
14+
val inputs = TestInputs(
15+
os.rel / "args.txt" -> """|-release
16+
|8""".stripMargin,
17+
os.rel / fileName ->
18+
s"""|import java.net.http.HttpClient
19+
|
20+
|println("Hello :)")
21+
|""".stripMargin
22+
)
23+
24+
inputs.fromRoot { root =>
25+
val res = os.proc(TestUtil.cli, serverArgs, "@args.txt", fileName).call(
26+
cwd = root,
27+
check = false,
28+
stderr = os.Pipe
29+
)
30+
assert(res.exitCode == 1)
31+
32+
val compilationError = res.err.text()
33+
assert(compilationError.contains("Compilation failed"))
34+
}
35+
}
36+
37+
if (!Properties.isWin)
38+
test("pass scalac options using arguments file in shebang script") {
39+
val inputs = TestInputs(
40+
os.rel / "args.txt" -> """|-release 8""".stripMargin,
41+
os.rel / "script-with-shebang" ->
42+
s"""|#!/usr/bin/env -S ${TestUtil.cli.mkString(" ")} shebang @args.txt
43+
|import java.net.http.HttpClient
44+
|
45+
|println("Hello :)")
46+
|""".stripMargin
47+
)
48+
49+
inputs.fromRoot { root =>
50+
os.perms.set(root / "script-with-shebang", os.PermSet.fromString("rwx------"))
51+
val res = os.proc("./script-with-shebang").call(cwd = root, check = false, stderr = os.Pipe)
52+
assert(res.exitCode == 1)
53+
54+
val compilationError = res.err.text()
55+
assert(compilationError.contains("Compilation failed"))
56+
}
57+
}
58+
59+
test("multiple args files") {
60+
val preCompileDir = "PreCompileDir"
61+
val runDir = "RunDir"
62+
63+
val preCompiledInput = "Message.scala"
64+
val mainInput = "Main.scala"
65+
66+
val expectedOutput = "Hello"
67+
68+
val outputDir = os.rel / "out"
69+
70+
TestInputs(
71+
os.rel / preCompileDir / preCompiledInput -> "case class Message(value: String)",
72+
os.rel / runDir / mainInput -> s"""object Main extends App { println(Message("$expectedOutput").value) }""",
73+
os.rel / runDir / "args.txt" -> s"""|-d
74+
|$outputDir""".stripMargin,
75+
os.rel / runDir / "args2.txt" -> s"""|-cp
76+
|${os.rel / os.up / preCompileDir / outputDir}""".stripMargin
77+
).fromRoot { (root: os.Path) =>
78+
79+
os.proc(
80+
TestUtil.cli,
81+
"compile",
82+
"--scala-opt",
83+
"-d",
84+
"--scala-opt",
85+
outputDir.toString,
86+
preCompiledInput
87+
).call(cwd = root / preCompileDir, stderr = os.Pipe)
88+
assert((root / preCompileDir / outputDir / "Message.class").toNIO.toFile().exists())
89+
90+
val compileOutput = root / runDir / outputDir
91+
os.makeDir.all(compileOutput)
92+
val runRes = os.proc(
93+
TestUtil.cli,
94+
"run",
95+
"@args.txt",
96+
"--server=false",
97+
"@args2.txt",
98+
mainInput
99+
).call(cwd = root / runDir, stderr = os.Pipe)
100+
assert(runRes.out.trim() == expectedOutput)
101+
assert((compileOutput / "Main.class").toNIO.toFile().exists())
102+
}
103+
}
104+
105+
}

website/docs/reference/cli-options.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1408,6 +1408,10 @@ Available in commands:
14081408

14091409
<!-- Automatically generated, DO NOT EDIT MANUALLY -->
14101410

1411+
### `--args-file`
1412+
1413+
File with scalac options.
1414+
14111415
### `--scalac-option`
14121416

14131417
Aliases: `-O`, `--scala-opt`, `--scala-option`

website/docs/reference/scala-command/cli-options.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -914,6 +914,12 @@ Available in commands:
914914

915915
<!-- Automatically generated, DO NOT EDIT MANUALLY -->
916916

917+
### `--args-file`
918+
919+
`IMPLEMENTATION specific` per Scala Runner specification
920+
921+
File with scalac options.
922+
917923
### `--scalac-option`
918924

919925
Aliases: `-O`, `--scala-opt`, `--scala-option`

0 commit comments

Comments
 (0)