All Downloads are FREE. Search and download functionalities are using the official Maven repository.

scala.build.CrossSources.scala Maven / Gradle / Ivy

package scala.build

import java.io.File

import scala.build.CollectionOps.*
import scala.build.EitherCps.{either, value}
import scala.build.Ops.*
import scala.build.Positioned
import scala.build.errors.{
  BuildException,
  CompositeBuildException,
  ExcludeDefinitionError,
  MalformedDirectiveError,
  Severity
}
import scala.build.input.ElementsUtils.*
import scala.build.input.*
import scala.build.internal.Constants
import scala.build.internal.util.{RegexUtils, WarningMessages}
import scala.build.options.{
  BuildOptions,
  BuildRequirements,
  MaybeScalaVersion,
  Scope,
  SuppressWarningOptions,
  WithBuildRequirements
}
import scala.build.preprocessing.*
import scala.build.testrunner.DynamicTestRunner.globPattern
import scala.util.Try
import scala.util.chaining.*

/** Information gathered from preprocessing command inputs - sources (including unwrapped scripts)
  * and build options from using directives
  *
  * @param paths
  *   paths and realtive paths to sources on disk, wrapped in their build requirements
  * @param inMemory
  *   in memory sources (e.g. snippets) wrapped in their build requirements
  * @param defaultMainClass
  * @param resourceDirs
  * @param buildOptions
  *   build options from sources
  * @param unwrappedScripts
  *   in memory script sources, their code must be wrapped before compiling
  */
final case class CrossSources(
  paths: Seq[WithBuildRequirements[(os.Path, os.RelPath)]],
  inMemory: Seq[WithBuildRequirements[Sources.InMemory]],
  defaultMainElemPath: Option[os.Path],
  resourceDirs: Seq[WithBuildRequirements[os.Path]],
  buildOptions: Seq[WithBuildRequirements[BuildOptions]],
  unwrappedScripts: Seq[WithBuildRequirements[Sources.UnwrappedScript]]
) {
  def sharedOptions(baseOptions: BuildOptions): BuildOptions =
    buildOptions
      .filter(_.requirements.isEmpty)
      .map(_.value)
      .foldLeft(baseOptions)(_ orElse _)

  private def needsScalaVersion =
    paths.exists(_.needsScalaVersion) ||
    inMemory.exists(_.needsScalaVersion) ||
    resourceDirs.exists(_.needsScalaVersion) ||
    buildOptions.exists(_.needsScalaVersion)

  def scopedSources(baseOptions: BuildOptions): Either[BuildException, ScopedSources] = either {

    val sharedOptions0 = sharedOptions(baseOptions)

    // FIXME Not 100% sure the way we compute the intermediate and final BuildOptions
    // is consistent (we successively filter out / retain options to compute a scala
    // version and platform, which might not be the version and platform of the final
    // BuildOptions).

    val crossSources0 =
      if (needsScalaVersion) {

        val retainedScalaVersion = value(sharedOptions0.scalaParams)
          .map(p => MaybeScalaVersion(p.scalaVersion))
          .getOrElse(MaybeScalaVersion.none)

        val buildOptionsWithScalaVersion = buildOptions
          .flatMap(_.withScalaVersion(retainedScalaVersion).toSeq)
          .filter(_.requirements.isEmpty)
          .map(_.value)
          .foldLeft(sharedOptions0)(_ orElse _)

        val platform = buildOptionsWithScalaVersion.platform

        copy(
          paths = paths
            .flatMap(_.withScalaVersion(retainedScalaVersion).toSeq)
            .flatMap(_.withPlatform(platform.value).toSeq),
          inMemory = inMemory
            .flatMap(_.withScalaVersion(retainedScalaVersion).toSeq)
            .flatMap(_.withPlatform(platform.value).toSeq),
          resourceDirs = resourceDirs
            .flatMap(_.withScalaVersion(retainedScalaVersion).toSeq)
            .flatMap(_.withPlatform(platform.value).toSeq),
          buildOptions = buildOptions
            .filter(!_.requirements.isEmpty)
            .flatMap(_.withScalaVersion(retainedScalaVersion).toSeq)
            .flatMap(_.withPlatform(platform.value).toSeq),
          unwrappedScripts = unwrappedScripts
            .flatMap(_.withScalaVersion(retainedScalaVersion).toSeq)
            .flatMap(_.withPlatform(platform.value).toSeq)
        )
      }
      else {

        val platform = sharedOptions0.platform

        copy(
          paths = paths
            .flatMap(_.withPlatform(platform.value).toSeq),
          inMemory = inMemory
            .flatMap(_.withPlatform(platform.value).toSeq),
          resourceDirs = resourceDirs
            .flatMap(_.withPlatform(platform.value).toSeq),
          buildOptions = buildOptions
            .filter(!_.requirements.isEmpty)
            .flatMap(_.withPlatform(platform.value).toSeq),
          unwrappedScripts = unwrappedScripts
            .flatMap(_.withPlatform(platform.value).toSeq)
        )
      }

    val defaultScope: Scope = Scope.Main
    ScopedSources(
      crossSources0.paths.map(_.scopedValue(defaultScope)),
      crossSources0.inMemory.map(_.scopedValue(defaultScope)),
      defaultMainElemPath,
      crossSources0.resourceDirs.map(_.scopedValue(defaultScope)),
      crossSources0.buildOptions.map(_.scopedValue(defaultScope)),
      crossSources0.unwrappedScripts.map(_.scopedValue(defaultScope))
    )
  }
}

object CrossSources {

  private def withinTestSubDirectory(p: ScopePath, inputs: Inputs): Boolean =
    p.root.exists { path =>
      val fullPath = path / p.subPath
      inputs.elements.exists {
        case Directory(path) =>
          // Is this file subdirectory of given dir and if we have a subdiretory 'test' on the way
          fullPath.startsWith(path) &&
          fullPath.relativeTo(path).segments.contains("test")
        case _ => false
      }
    }

  /** @return
    *   a CrossSources and Inputs which contains element processed from using directives
    */
  def forInputs(
    inputs: Inputs,
    preprocessors: Seq[Preprocessor],
    logger: Logger,
    suppressWarningOptions: SuppressWarningOptions,
    exclude: Seq[Positioned[String]] = Nil,
    maybeRecoverOnError: BuildException => Option[BuildException] = e => Some(e)
  )(using ScalaCliInvokeData): Either[BuildException, (CrossSources, Inputs)] = either {

    def preprocessSources(elems: Seq[SingleElement])
      : Either[BuildException, Seq[PreprocessedSource]] =
      elems
        .map { elem =>
          preprocessors
            .iterator
            .flatMap(p =>
              p.preprocess(
                elem,
                logger,
                maybeRecoverOnError,
                inputs.allowRestrictedFeatures,
                suppressWarningOptions
              ).iterator
            )
            .take(1)
            .toList
            .headOption
            .getOrElse(Right(Nil)) // FIXME Warn about unprocessed stuff?
        }
        .sequence
        .left.map(CompositeBuildException(_))
        .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))
          .flatMap(_.options).flatMap(_.internal.exclude)
      exclude ++ excludeFromProjectFile
    }

    val preprocessedInputFromArgs: Seq[PreprocessedSource] =
      value(
        preprocessSources(value(excludeSources(flattenedInputs, inputs.workspace, allExclude)))
      )

    val sourcesFromDirectives =
      preprocessedInputFromArgs
        .flatMap(_.options)
        .flatMap(_.internal.extraSourceFiles)
        .distinct
    val inputsElemFromDirectives: Seq[SingleFile] =
      value(resolveInputsFromSources(sourcesFromDirectives, inputs.enableMarkdown))
    val preprocessedSourcesFromDirectives: Seq[PreprocessedSource] =
      value(preprocessSources(inputsElemFromDirectives.pipe(elements =>
        value(excludeSources(elements, inputs.workspace, allExclude))
      )))

    warnAboutChainedUsingFileDirectives(preprocessedSourcesFromDirectives, logger)

    val allInputs = inputs.add(inputsElemFromDirectives).pipe(inputs =>
      val filteredElements = value(excludeSources(inputs.elements, inputs.workspace, allExclude))
      inputs.withElements(elements = filteredElements)
    )

    val preprocessedSources: Seq[PreprocessedSource] =
      (preprocessedInputFromArgs ++ preprocessedSourcesFromDirectives).distinct
        .pipe { sources =>
          val validatedSources: Seq[PreprocessedSource] =
            value(validateExcludeDirectives(sources, allInputs.workspace))
          val distinctSources = validatedSources.distinctBy(_.distinctPathOrSource)
          val diff            = validatedSources.diff(distinctSources)
          if diff.nonEmpty then
            val diffString = diff.map(_.distinctPathOrSource).mkString(s"${System.lineSeparator}  ")
            logger.message(
              s"""[${Console.YELLOW}warn${Console.RESET}] Skipped duplicate sources:
                 |  $diffString""".stripMargin
            )
          distinctSources
        }

    logger.flushExperimentalWarnings

    val scopedRequirements       = preprocessedSources.flatMap(_.scopedRequirements)
    val scopedRequirementsByRoot = scopedRequirements.groupBy(_.path.root)
    def baseReqs(path: ScopePath): BuildRequirements = {
      val fromDirectives =
        scopedRequirementsByRoot
          .getOrElse(path.root, Nil)
          .flatMap(_.valueFor(path).toSeq)
          .foldLeft(BuildRequirements())(_ orElse _)

      // Scala CLI treats all `.test.scala` files tests as well as
      // files from within `test` subdirectory from provided input directories
      // If file has `using target ` directive this take precendeces.
      if (
        fromDirectives.scope.isEmpty &&
        (path.subPath.last.endsWith(".test.scala") || withinTestSubDirectory(path, allInputs))
      )
        fromDirectives.copy(scope = Some(BuildRequirements.ScopeRequirement(Scope.Test)))
      else fromDirectives
    }

    val buildOptions: Seq[WithBuildRequirements[BuildOptions]] = (for {
      preprocessedSource <- preprocessedSources
      opts               <- preprocessedSource.options.toSeq
      if opts != BuildOptions() || preprocessedSource.optionsWithTargetRequirements.nonEmpty
    } yield {
      val baseReqs0 = baseReqs(preprocessedSource.scopePath)
      preprocessedSource.optionsWithTargetRequirements :+ WithBuildRequirements(
        preprocessedSource.requirements.fold(baseReqs0)(_ orElse baseReqs0),
        opts
      )
    }).flatten

    val defaultMainElemPath = for {
      defaultMainElem <- allInputs.defaultMainClassElement
    } yield defaultMainElem.path

    val pathsWithDirectivePositions
      : Seq[(WithBuildRequirements[(os.Path, os.RelPath)], Option[Position.File])] =
      preprocessedSources.collect {
        case d: PreprocessedSource.OnDisk =>
          val baseReqs0 = baseReqs(d.scopePath)
          WithBuildRequirements(
            d.requirements.fold(baseReqs0)(_ orElse baseReqs0),
            (d.path, d.path.relativeTo(allInputs.workspace))
          ) -> d.directivesPositions
      }
    val inMemoryWithDirectivePositions
      : Seq[(WithBuildRequirements[Sources.InMemory], Option[Position.File])] =
      preprocessedSources.collect {
        case m: PreprocessedSource.InMemory =>
          val baseReqs0 = baseReqs(m.scopePath)
          WithBuildRequirements(
            m.requirements.fold(baseReqs0)(_ orElse baseReqs0),
            Sources.InMemory(m.originalPath, m.relPath, m.content, m.wrapperParamsOpt)
          ) -> m.directivesPositions
      }
    val unwrappedScriptsWithDirectivePositions
      : Seq[(WithBuildRequirements[Sources.UnwrappedScript], Option[Position.File])] =
      preprocessedSources.collect {
        case m: PreprocessedSource.UnwrappedScript =>
          val baseReqs0 = baseReqs(m.scopePath)
          WithBuildRequirements(
            m.requirements.fold(baseReqs0)(_ orElse baseReqs0),
            Sources.UnwrappedScript(m.originalPath, m.relPath, m.wrapScriptFun)
          ) -> m.directivesPositions
      }

    val resourceDirs: Seq[WithBuildRequirements[os.Path]] =
      resolveResourceDirs(allInputs, preprocessedSources)

    lazy val allPathsWithDirectivesByScope: Map[Scope, Seq[(os.Path, Position.File)]] =
      (pathsWithDirectivePositions ++ inMemoryWithDirectivePositions ++ unwrappedScriptsWithDirectivePositions)
        .flatMap { (withBuildRequirements, directivesPositions) =>
          val scope = withBuildRequirements.scopedValue(Scope.Main).scope
          val path: os.Path = withBuildRequirements.value match
            case im: Sources.InMemory =>
              im.originalPath match
                case Right((_, p: os.Path)) => p
                case _                      => inputs.workspace / im.generatedRelPath
            case us: Sources.UnwrappedScript =>
              us.originalPath match
                case Right((_, p: os.Path)) => p
                case _                      => inputs.workspace / us.generatedRelPath
            case (p: os.Path, _) => p
          directivesPositions.map((path, scope, _))
        }
        .groupBy((_, scope, _) => scope)
        .view
        .mapValues(_.map((path, _, directivesPositions) => path -> directivesPositions))
        .toMap
    lazy val anyScopeHasMultipleSourcesWithDirectives =
      Scope.all.exists(allPathsWithDirectivesByScope.get(_).map(_.length).getOrElse(0) > 1)
    val shouldSuppressWarning =
      suppressWarningOptions.suppressDirectivesInMultipleFilesWarning.getOrElse(false)
    if !shouldSuppressWarning && anyScopeHasMultipleSourcesWithDirectives then {
      val projectFilePath = inputs.elements.projectSettingsFiles.headOption match
        case Some(s) => s.path
        case _       => inputs.workspace / Constants.projectFileName
      allPathsWithDirectivesByScope
        .values
        .flatten
        .filter((path, _) => ScopePath.fromPath(path) != ScopePath.fromPath(projectFilePath))
        .pipe { pathsToReport =>
          val diagnosticMessage = WarningMessages
            .directivesInMultipleFilesWarning(projectFilePath.toString)
          val cliFriendlyMessage = WarningMessages.directivesInMultipleFilesWarning(
            projectFilePath.toString,
            pathsToReport.map(_._2.render())
          )

          logger.cliFriendlyDiagnostic(
            message = diagnosticMessage,
            cliFriendlyMessage = cliFriendlyMessage,
            positions = pathsToReport.map(_._2).toSeq
          )
        }
    }

    val paths            = pathsWithDirectivePositions.map(_._1)
    val inMemory         = inMemoryWithDirectivePositions.map(_._1)
    val unwrappedScripts = unwrappedScriptsWithDirectivePositions.map(_._1)
    (
      CrossSources(
        paths,
        inMemory,
        defaultMainElemPath,
        resourceDirs,
        buildOptions,
        unwrappedScripts
      ),
      allInputs
    )
  }

  /** @return
    *   the resource directories that should be added to the classpath
    */
  private def resolveResourceDirs(
    allInputs: Inputs,
    preprocessedSources: Seq[PreprocessedSource]
  ): Seq[WithBuildRequirements[os.Path]] = {
    val fromInputs = allInputs.elements
      .collect { case r: ResourceDirectory => WithBuildRequirements(BuildRequirements(), r.path) }
    val fromSources =
      preprocessedSources.flatMap(_.options)
        .flatMap(_.classPathOptions.resourcesDir)
        .map(r => WithBuildRequirements(BuildRequirements(), r))
    val fromSourcesWithRequirements = preprocessedSources
      .flatMap(_.optionsWithTargetRequirements)
      .flatMap(_.map(_.classPathOptions.resourcesDir).flatten)
    fromInputs ++ fromSources ++ fromSourcesWithRequirements
  }

  private def resolveInputsFromSources(sources: Seq[Positioned[os.Path]], enableMarkdown: Boolean) =
    sources.map { source =>
      val sourcePath   = source.value
      lazy val dir     = sourcePath / os.up
      lazy val subPath = sourcePath.subRelativeTo(dir)
      if (os.isDir(sourcePath))
        Right(Directory(sourcePath).singleFilesFromDirectory(enableMarkdown))
      else if (sourcePath == os.sub / Constants.projectFileName)
        Right(Seq(ProjectScalaFile(dir, subPath)))
      else if (sourcePath.ext == "scala") Right(Seq(SourceScalaFile(dir, subPath)))
      else if (sourcePath.ext == "sc") Right(Seq(Script(dir, subPath, None)))
      else if (sourcePath.ext == "java") Right(Seq(JavaFile(dir, subPath)))
      else if (sourcePath.ext == "jar") Right(Seq(JarFile(dir, subPath)))
      else if (sourcePath.ext == "md") Right(Seq(MarkdownFile(dir, subPath)))
      else {
        val msg =
          if (os.exists(sourcePath))
            s"$sourcePath: unrecognized source type (expected .scala, .sc, .java extension or directory) in using directive."
          else s"$sourcePath: not found path defined in using directive."
        Left(new MalformedDirectiveError(msg, source.positions))
      }
    }.sequence
      .left.map(CompositeBuildException(_))
      .map(_.flatten)

  /** Filters out the sources from the input sequence based on the provided 'exclude' patterns. The
    * exclude patterns can be absolute paths, relative paths, or glob patterns.
    *
    * @throws BuildException
    *   If multiple 'exclude' patterns are defined across the input sources.
    */
  private def excludeSources[E <: Element](
    elements: Seq[E],
    workspaceDir: os.Path,
    exclude: Seq[Positioned[String]]
  ): Either[BuildException, Seq[E]] = either {
    val excludePatterns = exclude.map(_.value).flatMap { p =>
      val maybeRelPath = Try(os.RelPath(p)).toOption
      maybeRelPath match {
        case Some(relPath) if os.isDir(workspaceDir / relPath) =>
          // exclude relative directory paths, add * to exclude all files in the directory
          Seq(p, (workspaceDir / relPath / "*").toString)
        case Some(relPath) =>
          Seq(p, (workspaceDir / relPath).toString) // exclude relative paths
        case None => Seq(p)
      }
    }

    def isSourceIncluded(path: String, excludePatterns: Seq[String]): Boolean =
      excludePatterns
        .forall(pattern => !RegexUtils.globPattern(pattern).matcher(path).matches())

    elements.filter {
      case e: OnDisk => isSourceIncluded(e.path.toString, excludePatterns)
      case _         => true
    }
  }

  /** Validates that exclude directives are defined only in the one source.
    */
  def validateExcludeDirectives(
    sources: Seq[PreprocessedSource],
    workspaceDir: os.Path
  ): Either[BuildException, Seq[PreprocessedSource]] = {
    val excludeDirectives = sources.flatMap(_.options).map(_.internal.exclude).toList.flatten

    excludeDirectives match {
      case Nil | Seq(_) =>
        Right(sources)
      case _ =>
        val expectedProjectFilePath = workspaceDir / Constants.projectFileName
        Left(new ExcludeDefinitionError(
          excludeDirectives.flatMap(_.positions),
          expectedProjectFilePath
        ))
    }
  }

  /** When a source file added by a `using file` directive, itself, contains `using file` directives
    * there should be a warning printed that transitive `using file` directives are not supported.
    */
  def warnAboutChainedUsingFileDirectives(
    sourcesAddedWithDirectives: Seq[PreprocessedSource],
    logger: Logger
  ): Unit = for {
    additionalSource           <- sourcesAddedWithDirectives
    buildOptions               <- additionalSource.options
    transitiveAdditionalSource <- buildOptions.internal.extraSourceFiles
  } do
    logger.diagnostic(
      WarningMessages.chainingUsingFileDirective,
      Severity.Warning,
      transitiveAdditionalSource.positions
    )
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy