Skip to content
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
47 changes: 29 additions & 18 deletions modules/build/src/main/scala/scala/build/CrossSources.scala
Original file line number Diff line number Diff line change
Expand Up @@ -165,16 +165,18 @@ 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
.iterator
.flatMap(p =>
p.preprocess(
elem,
logger,
preprocessLogger,
maybeRecoverOnError,
inputs.allowRestrictedFeatures,
suppressWarningOptions
Expand All @@ -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] =
Expand Down Expand Up @@ -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],
Expand All @@ -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
Expand Down
122 changes: 122 additions & 0 deletions modules/build/src/test/scala/scala/build/tests/ExcludeTests.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
8 changes: 4 additions & 4 deletions website/docs/commands/compile.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion website/docs/reference/directives.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_

Expand Down
2 changes: 1 addition & 1 deletion website/docs/reference/scala-command/directives.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_

Expand Down