From f27bf650b97e97c777fef6edae6194d542dd3d9a Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Sun, 22 Mar 2026 16:10:53 +0100 Subject: [PATCH] Support formatting .sbt inputs --- .../scala/scala/build/input/Element.scala | 5 +++ .../scala/build/input/ElementsUtils.scala | 3 ++ .../main/scala/scala/build/input/Inputs.scala | 2 ++ .../scala/scala/build/tests/InputsTests.scala | 36 +++++++++++++++---- .../scala/scala/cli/commands/fmt/Fmt.scala | 4 +-- .../scala/scala/cli/commands/run/Run.scala | 1 + .../integration/CompileTestDefinitions.scala | 13 +++++++ .../cli/integration/FixTestDefinitions.scala | 24 +++++++++++++ .../scala/cli/integration/FmtTests.scala | 31 ++++++++++++++++ .../integration/PackageTestDefinitions.scala | 22 ++++++++++++ .../cli/integration/RunTestDefinitions.scala | 15 ++++++++ 11 files changed, 147 insertions(+), 9 deletions(-) diff --git a/modules/build/src/main/scala/scala/build/input/Element.scala b/modules/build/src/main/scala/scala/build/input/Element.scala index 5f2c941706..f89ea71f52 100644 --- a/modules/build/src/main/scala/scala/build/input/Element.scala +++ b/modules/build/src/main/scala/scala/build/input/Element.scala @@ -107,6 +107,11 @@ final case class MarkdownFile(base: os.Path, subPath: os.SubPath) lazy val path: os.Path = base / subPath } +final case class SbtFile(base: os.Path, subPath: os.SubPath) + extends OnDisk with SourceFile { + lazy val path: os.Path = base / subPath +} + final case class Directory(path: os.Path) extends OnDisk with Compiled final case class ResourceDirectory(path: os.Path) extends OnDisk diff --git a/modules/build/src/main/scala/scala/build/input/ElementsUtils.scala b/modules/build/src/main/scala/scala/build/input/ElementsUtils.scala index 2c79db3284..4e49678376 100644 --- a/modules/build/src/main/scala/scala/build/input/ElementsUtils.scala +++ b/modules/build/src/main/scala/scala/build/input/ElementsUtils.scala @@ -34,6 +34,8 @@ object ElementsUtils { case p if p.last.endsWith(".sc") => // TODO: hasShebang test without consuming 1st 2 bytes of Stream Script(d.path, p.subRelativeTo(d.path), None) + case p if p.last.endsWith(".sbt") => + SbtFile(d.path, p.subRelativeTo(d.path)) } .toVector .sortBy(_.subPath.segments) @@ -68,6 +70,7 @@ object ElementsUtils { case _: Script => "sc:" case _: MarkdownFile => "md:" case _: JarFile => "jar:" + case _: SbtFile => "sbt:" } Iterator(prefix, elem.path.toString, "\n").map(bytes) case v: Virtual => diff --git a/modules/build/src/main/scala/scala/build/input/Inputs.scala b/modules/build/src/main/scala/scala/build/input/Inputs.scala index 055be9baa0..2e608b28db 100644 --- a/modules/build/src/main/scala/scala/build/input/Inputs.scala +++ b/modules/build/src/main/scala/scala/build/input/Inputs.scala @@ -104,6 +104,7 @@ final case class Inputs( Seq("dir:") ++ dirInput.singleFilesFromDirectory(enableMarkdown) .map(file => s"${file.path}:" + os.read(file.path)) case _: ResourceDirectory => Nil + case _: SbtFile => Nil case _ => Seq(os.read(elem.path)) } (Iterator(elem.path.toString) ++ content.iterator ++ Iterator("\n")).map(bytes) @@ -282,6 +283,7 @@ object Inputs { else if arg.endsWith(".java") then Right(Seq(JavaFile(dir, subPath))) else if arg.endsWith(".jar") then Right(Seq(JarFile(dir, subPath))) else if arg.endsWith(".c") || arg.endsWith(".h") then Right(Seq(CFile(dir, subPath))) + else if arg.endsWith(".sbt") then Right(Seq(SbtFile(dir, subPath))) else if arg.endsWith(".md") then Right(Seq(MarkdownFile(dir, subPath))) else if acceptFds && arg.startsWith("/dev/fd/") then Right(Seq(VirtualScript(content, arg, os.sub / s"input-${idx + 1}.sc"))) diff --git a/modules/build/src/test/scala/scala/build/tests/InputsTests.scala b/modules/build/src/test/scala/scala/build/tests/InputsTests.scala index 5cf10bdd3e..1707975241 100644 --- a/modules/build/src/test/scala/scala/build/tests/InputsTests.scala +++ b/modules/build/src/test/scala/scala/build/tests/InputsTests.scala @@ -3,14 +3,8 @@ package scala.build.tests import bloop.rifle.BloopRifleConfig import com.eed3si9n.expecty.Expecty.expect +import scala.build.input.* import scala.build.input.ElementsUtils.* -import scala.build.input.{ - Inputs, - ScalaCliInvokeData, - VirtualJavaFile, - VirtualScalaFile, - VirtualScript -} import scala.build.internal.Constants import scala.build.options.{BuildOptions, InternalOptions} import scala.build.tests.util.BloopServer @@ -127,6 +121,34 @@ class InputsTests extends TestUtil.ScalaCliBuildSuite { } } + test("sbt file is recognized as SbtFile when passed explicitly") { + TestInputs(os.rel / "build.sbt" -> "").fromRoot { root => + val elements = Inputs.validateArgs( + Seq((root / "build.sbt").toString), + root, + download = _ => Right(Array.emptyByteArray), + stdinOpt = None, + acceptFds = false, + enableMarkdown = false + )(using ScalaCliInvokeData.dummy) + elements match { + case Seq(Right(Seq(f: SbtFile))) => + assert(f.path == root / "build.sbt") + case _ => fail(s"Unexpected elements: $elements") + } + } + } + + test("sbt file is picked up from directory scan") { + TestInputs(os.rel / "build.sbt" -> "").fromRoot { root => + val dir = Directory(root) + val singles = dir.singleFilesFromDirectory(enableMarkdown = false) + val sbtFiles = singles.collect { case f: SbtFile => f } + assert(sbtFiles.nonEmpty) + assert(sbtFiles.head.path == root / "build.sbt") + } + } + test("URLs with query parameters") { val urlBase = "https://gist.githubusercontent.com/USER/hash/raw/hash" diff --git a/modules/cli/src/main/scala/scala/cli/commands/fmt/Fmt.scala b/modules/cli/src/main/scala/scala/cli/commands/fmt/Fmt.scala index 96c24b96db..f08c247048 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/fmt/Fmt.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/fmt/Fmt.scala @@ -5,7 +5,7 @@ import caseapp.core.help.HelpFormat import dependency.* import scala.build.Logger -import scala.build.input.{ProjectScalaFile, Script, SourceScalaFile} +import scala.build.input.{ProjectScalaFile, SbtFile, Script, SourceScalaFile} import scala.build.internal.{Constants, ExternalBinaryParams, FetchExternalBinary, Runner} import scala.build.internals.ConsoleUtils.ScalaCliConsole.warnPrefix import scala.cli.CurrentParams @@ -53,7 +53,7 @@ object Fmt extends ScalaCommand[FmtOptions] { if args.all.isEmpty then (Seq(os.pwd), os.pwd, None) else { val i = options.shared.inputs(args.all).orExit(logger) - type FormattableSourceFile = Script | SourceScalaFile | ProjectScalaFile + type FormattableSourceFile = Script | SourceScalaFile | ProjectScalaFile | SbtFile val s = i.sourceFiles().collect { case sc: FormattableSourceFile => sc.path } (s, i.workspace, Some(i)) } diff --git a/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala b/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala index 78b53b5bfb..c13b1e15ce 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala @@ -600,6 +600,7 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers { case s: ScalaFile => fwd(s.path.toString) case s: Script => fwd(s.path.toString) case s: MarkdownFile => fwd(s.path.toString) + case _: SbtFile => "" case s: OnDisk => fwd(s.path.toString) case s => s.getClass.getName }.filter(_.nonEmpty).distinct diff --git a/modules/integration/src/test/scala/scala/cli/integration/CompileTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/CompileTestDefinitions.scala index 61efd2a41f..b4e5fdfeaa 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/CompileTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/CompileTestDefinitions.scala @@ -972,4 +972,17 @@ abstract class CompileTestDefinitions } } } + + test("sbt file in directory does not break compile") { + TestInputs( + os.rel / "Main.scala" -> + """object Main { + | def main(args: Array[String]): Unit = println("Hello") + |} + |""".stripMargin, + os.rel / "build.sbt" -> """name := "my-project"""" + ).fromRoot { root => + os.proc(TestUtil.cli, "compile", extraOptions, ".").call(cwd = root) + } + } } diff --git a/modules/integration/src/test/scala/scala/cli/integration/FixTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/FixTestDefinitions.scala index b7e060246f..c0355bd4cb 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/FixTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/FixTestDefinitions.scala @@ -68,6 +68,30 @@ abstract class FixTestDefinitions } } + test("sbt file in directory does not break fix") { + TestInputs( + os.rel / "Main.scala" -> + """object Main { + | def main(args: Array[String]): Unit = println("Hello") + |} + |""".stripMargin, + os.rel / "build.sbt" -> """name := "my-project"""", + os.rel / scalafixConfFileName -> + """rules = [ + | RedundantSyntax + |] + |""".stripMargin + ).fromRoot { root => + os.proc( + TestUtil.cli, + "--power", + "fix", + ".", + extraOptions + ).call(cwd = root) + } + } + def filterDebugOutputs(output: String): String = output .linesIterator diff --git a/modules/integration/src/test/scala/scala/cli/integration/FmtTests.scala b/modules/integration/src/test/scala/scala/cli/integration/FmtTests.scala index ace4fef4b4..afd439f1bc 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/FmtTests.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/FmtTests.scala @@ -232,4 +232,35 @@ class FmtTests extends ScalaCliSuite { expect(updatedContent == expectedSimpleInputsFormattedContent) } } + + val sbtUnformattedContent: String = + """val message = "hello" + |""".stripMargin + val expectedSbtFormattedContent: String = noCrLf { + """val message = "hello" + |""".stripMargin + } + val sbtInputs: TestInputs = TestInputs( + os.rel / confFileName -> + s"""|version = "${Constants.defaultScalafmtVersion}" + |runner.dialect = scala213 + |""".stripMargin, + os.rel / "build.sbt" -> sbtUnformattedContent + ) + + test("sbt file is formatted when passed explicitly") { + sbtInputs.fromRoot { root => + os.proc(TestUtil.cli, "fmt", "build.sbt").call(cwd = root) + val updatedContent = noCrLf(os.read(root / "build.sbt")) + expect(updatedContent == expectedSbtFormattedContent) + } + } + + test("sbt file is formatted when directory is passed") { + sbtInputs.fromRoot { root => + os.proc(TestUtil.cli, "fmt", ".").call(cwd = root) + val updatedContent = noCrLf(os.read(root / "build.sbt")) + expect(updatedContent == expectedSbtFormattedContent) + } + } } diff --git a/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala index 784a94c356..97c20ec286 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/PackageTestDefinitions.scala @@ -1601,4 +1601,26 @@ abstract class PackageTestDefinitions extends ScalaCliSuite with TestScalaVersio } } } + + test("sbt file in directory does not break package") { + val message = "Hello from package" + TestInputs( + os.rel / "Main.scala" -> + s"""object Main { + | def main(args: Array[String]): Unit = println("$message") + |} + |""".stripMargin, + os.rel / "build.sbt" -> """name := "my-project"""" + ).fromRoot { root => + os.proc(TestUtil.cli, "--power", "package", extraOptions, ".").call( + cwd = root, + stdin = os.Inherit, + stdout = os.Inherit + ) + val launcher = root / (if Properties.isWin then "Main.bat" else "Main") + expect(os.isFile(launcher)) + val output = TestUtil.maybeUseBash(launcher)(cwd = root).out.trim() + expect(output == message) + } + } } 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 1d3517e2d6..6bb62f249e 100755 --- a/modules/integration/src/test/scala/scala/cli/integration/RunTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/RunTestDefinitions.scala @@ -2510,4 +2510,19 @@ abstract class RunTestDefinitions processes.foreach { case (p, _) => expect(p.exitCode() == 0) } } } + + test("sbt file in directory does not break run") { + val message = "Hello from run" + TestInputs( + os.rel / "Main.scala" -> + s"""object Main { + | def main(args: Array[String]): Unit = println("$message") + |} + |""".stripMargin, + os.rel / "build.sbt" -> """name := "my-project"""" + ).fromRoot { root => + val output = os.proc(TestUtil.cli, extraOptions, ".").call(cwd = root).out.trim() + expect(output == message) + } + } }