From fabba4a3137e1941f71353c913fade8204aa1866 Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Thu, 13 Aug 2026 14:17:04 +0200 Subject: [PATCH] Allow the `//> using exclude` directive in an `.sc` script rather than just project.file (but still only one place) --- .../main/scala/scala/build/CrossSources.scala | 47 ++++--- .../scala/build/tests/ExcludeTests.scala | 122 ++++++++++++++++++ .../build/errors/ExcludeDefinitionError.scala | 35 ++++- .../preprocessing/directives/Exclude.scala | 4 +- .../RunScriptTestDefinitions.scala | 21 +++ website/docs/commands/compile.md | 8 +- website/docs/reference/directives.md | 2 +- .../reference/scala-command/directives.md | 2 +- 8 files changed, 211 insertions(+), 30 deletions(-) diff --git a/modules/build/src/main/scala/scala/build/CrossSources.scala b/modules/build/src/main/scala/scala/build/CrossSources.scala index eb808d641c..7286d75464 100644 --- a/modules/build/src/main/scala/scala/build/CrossSources.scala +++ b/modules/build/src/main/scala/scala/build/CrossSources.scala @@ -165,8 +165,10 @@ object CrossSources { download: BuildOptions.Download = BuildOptions.Download.notSupported )(using ScalaCliInvokeData): Either[BuildException, (CrossSources, Inputs)] = either { - def preprocessSources(elems: Seq[SingleElement]) - : Either[BuildException, Seq[PreprocessedSource]] = + def preprocessSources( + elems: Seq[SingleElement], + preprocessLogger: Logger = logger + ): Either[BuildException, Seq[PreprocessedSource]] = elems .map { elem => preprocessors @@ -174,7 +176,7 @@ object CrossSources { .flatMap(p => p.preprocess( elem, - logger, + preprocessLogger, maybeRecoverOnError, inputs.allowRestrictedFeatures, suppressWarningOptions @@ -190,14 +192,16 @@ object CrossSources { .map(_.flatten) val flattenedInputs = inputs.flattened() - val allExclude = { // supports only one exclude directive in one source file, which should be the project file. - val projectScalaFileOpt = flattenedInputs.collectFirst { - case f: ProjectScalaFile => f - } - val excludeFromProjectFile = - value(preprocessSources(projectScalaFileOpt.toSeq)) + // Exclude may be declared in exactly one source: the project file or a script. + val allExclude = { + def excludesFrom(elements: Seq[SingleElement]): Seq[Positioned[String]] = + value(preprocessSources(elements, Logger.nop)) .flatMap(_.options).flatMap(_.internal.exclude) - exclude ++ excludeFromProjectFile + val fromProjectFile = + excludesFrom(flattenedInputs.collectFirst { case f: ProjectScalaFile => f }.toSeq) + val remaining = + value(excludeSources(flattenedInputs, inputs.workspace, exclude ++ fromProjectFile)) + exclude ++ fromProjectFile ++ excludesFrom(remaining.collect { case s: Script => s }) } val preprocessedInputFromArgs: Seq[PreprocessedSource] = @@ -488,7 +492,8 @@ object CrossSources { } } - /** Validates that exclude directives are defined only in the one source. + /** Validates that exclude directives are defined in exactly one allowed source: the workspace + * project file or a script. */ def validateExcludeDirectives( sources: Seq[PreprocessedSource], @@ -503,13 +508,19 @@ object CrossSources { val expectedProjectFilePath = workspaceDir / Constants.projectFileName - val singleSourceAtProject = excludePositions.forall { - case Position.File(Left(s), _, _, _) => workspaceDir / s == expectedProjectFilePath - case Position.File(Right(p), _, _, _) => p == expectedProjectFilePath - case _ => false - } - if (singleSourceAtProject) Right(sources) - else Left(new ExcludeDefinitionError(excludePositions, expectedProjectFilePath)) + def declaringPath(position: Position): Option[os.Path] = position match + case Position.File(Left(s), _, _, _) => Try(workspaceDir / os.RelPath(s)).toOption + case Position.File(Right(p), _, _, _) => Some(p) + case _ => None + + val declaringPaths = excludePositions.map(declaringPath).distinct + declaringPaths match + case Nil => Right(sources) + case Seq(Some(p)) if p == expectedProjectFilePath || p.isScript => Right(sources) + case Seq(_) => + Left(ExcludeDefinitionError.inUnsupportedFile(excludePositions, expectedProjectFilePath)) + case _ => + Left(ExcludeDefinitionError.inMultipleFiles(excludePositions, expectedProjectFilePath)) } /** When a source file added by a `using file` directive, itself, contains `using file` directives diff --git a/modules/build/src/test/scala/scala/build/tests/ExcludeTests.scala b/modules/build/src/test/scala/scala/build/tests/ExcludeTests.scala index 927f22a088..db8cb070a4 100644 --- a/modules/build/src/test/scala/scala/build/tests/ExcludeTests.scala +++ b/modules/build/src/test/scala/scala/build/tests/ExcludeTests.scala @@ -249,4 +249,126 @@ class ExcludeTests extends TestUtil.ScalaCliBuildSuite { } } + test("exclude in a script") { + val testInputs = TestInputs( + os.rel / "Hello.scala" -> "object Hello", + os.rel / "Other.scala" -> "object Other", + os.rel / "main.sc" -> + """//> using exclude Other.scala + |println("hi") + |""".stripMargin + ) + testInputs.withInputs { (root, inputs) => + val (crossSources, _) = + CrossSources.forInputs( + inputs, + preprocessors, + TestLogger(), + SuppressWarningOptions() + )(using ScalaCliInvokeData.dummy).orThrow + val scopedSources = crossSources.scopedSources(BuildOptions()).orThrow + val sources = + scopedSources.sources( + Scope.Main, + crossSources.sharedOptions(BuildOptions()), + root, + TestLogger() + ).orThrow + + val onDiskPaths = sources.paths.map(_._2) + val expectedOnDisk = Seq(os.rel / "Hello.scala") + expect(onDiskPaths == expectedOnDisk) + val inMemoryPaths = sources.inMemory.map(_.generatedRelPath) + val expectedInMemory = Seq(os.rel / "main.scala") + expect(inMemoryPaths == expectedInMemory) + } + } + + test("exclude in a script pulling sources via using file") { + val testInputs = TestInputs( + os.rel / "Helper.scala" -> "object Helper", + os.rel / "Other.scala" -> "object Other", + os.rel / "main.sc" -> + """//> using file Helper.scala + |//> using exclude Other.scala + |println(Helper) + |""".stripMargin + ) + testInputs.withInputs { (root, inputs) => + val (crossSources, _) = + CrossSources.forInputs( + inputs, + preprocessors, + TestLogger(), + SuppressWarningOptions() + )(using ScalaCliInvokeData.dummy).orThrow + val scopedSources = crossSources.scopedSources(BuildOptions()).orThrow + val sources = + scopedSources.sources( + Scope.Main, + crossSources.sharedOptions(BuildOptions()), + root, + TestLogger() + ).orThrow + + val onDiskPaths = sources.paths.map(_._2) + val expectedOnDisk = Seq(os.rel / "Helper.scala") + expect(onDiskPaths == expectedOnDisk) + val inMemoryPaths = sources.inMemory.map(_.generatedRelPath) + val expectedInMemory = Seq(os.rel / "main.scala") + expect(inMemoryPaths == expectedInMemory) + } + } + + test("error message when exclude is in an unsupported file") { + val testInputs = TestInputs( + os.rel / "Main.scala" -> + """//> using exclude Other.scala + |""".stripMargin, + os.rel / "Other.scala" -> "object Other" + ) + testInputs.withInputs { (_, inputs) => + val crossSources = + CrossSources.forInputs( + inputs, + preprocessors, + TestLogger(), + SuppressWarningOptions() + )(using ScalaCliInvokeData.dummy) + crossSources match { + case Left(e: ExcludeDefinitionError) => + val msg = e.message + expect(msg.contains("`.sc` script")) + expect(msg.contains("project.scala")) + case o => fail("Exception expected", clues(o)) + } + } + } + + test("error when exclude is declared in both project.scala and a script") { + val testInputs = TestInputs( + os.rel / "project.scala" -> "//> using exclude Other.scala", + os.rel / "main.sc" -> + """//> using exclude Hello.scala + |println("hi") + |""".stripMargin, + os.rel / "Hello.scala" -> "object Hello", + os.rel / "Other.scala" -> "object Other" + ) + testInputs.withInputs { (_, inputs) => + val crossSources = + CrossSources.forInputs( + inputs, + preprocessors, + TestLogger(), + SuppressWarningOptions() + )(using ScalaCliInvokeData.dummy) + crossSources match { + case Left(e: ExcludeDefinitionError) => + expect(e.message.contains("single source file")) + case o => fail("Exception expected", clues(o)) + } + } + } + } diff --git a/modules/core/src/main/scala/scala/build/errors/ExcludeDefinitionError.scala b/modules/core/src/main/scala/scala/build/errors/ExcludeDefinitionError.scala index 20fc2a2351..0f94618a45 100644 --- a/modules/core/src/main/scala/scala/build/errors/ExcludeDefinitionError.scala +++ b/modules/core/src/main/scala/scala/build/errors/ExcludeDefinitionError.scala @@ -2,9 +2,34 @@ package scala.build.errors import scala.build.Position -final class ExcludeDefinitionError(positions: Seq[Position], expectedProjectFilePath: os.Path) - extends BuildException( - s"""Found exclude directives in files: - | ${positions.map(_.render()).distinct.mkString(", ")} - |exclude directive must be defined in project configuration file: $expectedProjectFilePath.""".stripMargin +final class ExcludeDefinitionError private ( + message: String, + positions: Seq[Position] +) extends BuildException(message, positions) + +object ExcludeDefinitionError { + + private def renderedPositions(positions: Seq[Position]): String = + positions.map(_.render()).distinct.mkString(", ") + + def inUnsupportedFile( + positions: Seq[Position], + expectedProjectFilePath: os.Path + ): ExcludeDefinitionError = + new ExcludeDefinitionError( + s"""The `//> using exclude` directive can only be declared in the project configuration file ($expectedProjectFilePath) or in a `.sc` script, but it was found in: + | ${renderedPositions(positions)}""".stripMargin, + positions + ) + + def inMultipleFiles( + positions: Seq[Position], + expectedProjectFilePath: os.Path + ): ExcludeDefinitionError = + new ExcludeDefinitionError( + s"""The `//> using exclude` directive must be declared in a single source file, but it was found in: + | ${renderedPositions(positions)} + |It can only be declared in the project configuration file ($expectedProjectFilePath) or in a `.sc` script.""".stripMargin, + positions ) +} diff --git a/modules/directives/src/main/scala/scala/build/preprocessing/directives/Exclude.scala b/modules/directives/src/main/scala/scala/build/preprocessing/directives/Exclude.scala index 9ea816dd56..f3b6f1f6c7 100644 --- a/modules/directives/src/main/scala/scala/build/preprocessing/directives/Exclude.scala +++ b/modules/directives/src/main/scala/scala/build/preprocessing/directives/Exclude.scala @@ -18,7 +18,9 @@ import scala.cli.commands.SpecificationLevel |`//> using exclude` _pattern1_ _pattern2_ … |""".stripMargin ) -@DirectiveDescription("Exclude sources from the project") +@DirectiveDescription( + "Exclude sources from the project. Must be declared in a single source file: either the project configuration file (`project.scala`) or a `.sc` script." +) @DirectiveLevel(SpecificationLevel.SHOULD) final case class Exclude(exclude: List[Positioned[String]] = Nil) extends HasBuildOptions { def buildOptions: Either[BuildException, BuildOptions] = either { diff --git a/modules/integration/src/test/scala/scala/cli/integration/RunScriptTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/RunScriptTestDefinitions.scala index e9ab623c31..2c6d44457a 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/RunScriptTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/RunScriptTestDefinitions.scala @@ -33,6 +33,27 @@ trait RunScriptTestDefinitions { this: RunTestDefinitions => simpleScriptTest(extraArgs = Seq("-v")) } + test("exclude directive in a script") { + val message = "Hello from script" + val inputs = TestInputs( + os.rel / "main.sc" -> + s"""//> using exclude Other.scala + |println("$message") + |""".stripMargin, + os.rel / "Other.scala" -> + """object Other { + | val x: Int = "this would not compile" + |} + |""".stripMargin + ) + inputs.fromRoot { root => + val output = os.proc(TestUtil.cli, extraOptions, "main.sc", "Other.scala") + .call(cwd = root) + .out.trim() + expect(output == message) + } + } + test("Multiple scripts") { val message = "Hello" val inputs = TestInputs( diff --git a/website/docs/commands/compile.md b/website/docs/commands/compile.md index c480725366..a64092b4f5 100644 --- a/website/docs/commands/compile.md +++ b/website/docs/commands/compile.md @@ -496,13 +496,13 @@ line parameter `--exclude` along with a pattern: - a glob pattern: `*.sc` :::note -The `exclude` directive should be placed in your `project.scala` file, which Scala CLI uses to determine the project -root directory. -For more details on `project.file`, see [the `Project root directory` reference](../reference/root-dir.md). +The `exclude` directive must be declared in a single source file: either your `project.scala` file +(which Scala CLI uses to determine the project root directory) or a `.sc` script. +For more details on `project.scala`, see [the `Project root directory` reference](../reference/root-dir.md). ::: For example, to exclude all files in the `example/scala` directory, add the following directive to your - `project.file` file: + `project.scala` file: ```scala title=project.scala //> using exclude example/scala diff --git a/website/docs/reference/directives.md b/website/docs/reference/directives.md index f23847712f..0453e249f3 100644 --- a/website/docs/reference/directives.md +++ b/website/docs/reference/directives.md @@ -172,7 +172,7 @@ Add dependencies ### Exclude sources -Exclude sources from the project +Exclude sources from the project. Must be declared in a single source file: either the project configuration file (`project.scala`) or a `.sc` script. `//> using exclude` _pattern_ diff --git a/website/docs/reference/scala-command/directives.md b/website/docs/reference/scala-command/directives.md index 4f512295ae..aa50f06c8f 100644 --- a/website/docs/reference/scala-command/directives.md +++ b/website/docs/reference/scala-command/directives.md @@ -201,7 +201,7 @@ Manually add sources to the project. Does not support chaining, sources are adde ### Exclude sources -Exclude sources from the project +Exclude sources from the project. Must be declared in a single source file: either the project configuration file (`project.scala`) or a `.sc` script. `//> using exclude` _pattern_