diff --git a/modules/build/src/main/scala/scala/build/Build.scala b/modules/build/src/main/scala/scala/build/Build.scala index 3caf358329..b9fc5f7b1c 100644 --- a/modules/build/src/main/scala/scala/build/Build.scala +++ b/modules/build/src/main/scala/scala/build/Build.scala @@ -919,6 +919,9 @@ object Build { val semanticDbSourceRoot = options.scalaOptions.semanticDbOptions.semanticDbSourceRoot.getOrElse(inputs.workspace) + val sourceGenerators = + options.sourceGeneratorOptions.configs.values.toList.filter(_.command.nonEmpty) + val scalaCompilerParamsOpt = artifacts.scalaOpt match { case Some(scalaArtifacts) => val params = value(options.scalaParams).getOrElse { @@ -1056,7 +1059,8 @@ object Build { resourceDirs = sources.resourceDirs, scope = scope, javaHomeOpt = Option(options.javaHomeLocation().value), - javacOptions = javacOptions.toList + javacOptions = javacOptions.toList, + generators = Some(sourceGenerators).filter(_.nonEmpty) ) project } diff --git a/modules/build/src/main/scala/scala/build/Project.scala b/modules/build/src/main/scala/scala/build/Project.scala index f6d793be82..5cbb3299a7 100644 --- a/modules/build/src/main/scala/scala/build/Project.scala +++ b/modules/build/src/main/scala/scala/build/Project.scala @@ -10,7 +10,7 @@ import java.nio.charset.StandardCharsets import java.nio.file.Path import java.util.Arrays -import scala.build.options.{ScalacOpt, Scope, ShadowingSeq} +import scala.build.options.{ScalacOpt, Scope, ShadowingSeq, SourceGeneratorConfig} final case class Project( workspace: os.Path, @@ -28,7 +28,8 @@ final case class Project( resourceDirs: Seq[os.Path], javaHomeOpt: Option[os.Path], scope: Scope, - javacOptions: List[String] + javacOptions: List[String], + generators: Option[List[SourceGeneratorConfig]] ) { import Project._ @@ -50,6 +51,10 @@ final case class Project( bridgeJars = scalaCompiler0.bridgeJarsOpt.map(_.map(_.toNIO).toList) ) } + + val sourceGenerators: Option[List[BloopConfig.SourceGenerator]] = + generators.map(_.map(bloopSourceGenerator(_, workspace))) + baseBloopProject( projectName, directory.toNIO, @@ -65,7 +70,8 @@ final case class Project( platform = Some(platform), `scala` = scalaConfigOpt, java = Some(BloopConfig.Java(javacOptions)), - resolution = resolution + resolution = resolution, + sourceGenerators = sourceGenerators ) } @@ -231,4 +237,24 @@ object Project { setup = None, bridgeJars = None ) + + private def bloopSourceGenerator( + config: SourceGeneratorConfig, + currentDir: os.Path + ): BloopConfig.SourceGenerator = { + val sourcesGlobs = + BloopConfig.SourcesGlobs( + directory = config.inputDir.getOrElse(currentDir).toNIO, + walkDepth = None, // TODO: should this be added to config? + includes = config.glob.map(g => s"glob:$g"), + excludes = Nil // TODO: should this be added to config? + ) + + BloopConfig.SourceGenerator( + sourcesGlobs = List(sourcesGlobs), + outputDirectory = config.outputDir.getOrElse(currentDir).toNIO, + command = config.command, + unmanagedInputs = config.unmanaged.map(_.toNIO) + ) + } } diff --git a/modules/build/src/main/scala/scala/build/preprocessing/DirectivesPreprocessor.scala b/modules/build/src/main/scala/scala/build/preprocessing/DirectivesPreprocessor.scala index 15bde02c3b..dd7d40e482 100644 --- a/modules/build/src/main/scala/scala/build/preprocessing/DirectivesPreprocessor.scala +++ b/modules/build/src/main/scala/scala/build/preprocessing/DirectivesPreprocessor.scala @@ -1,6 +1,5 @@ package scala.build.preprocessing import scala.build.EitherCps.{either, value} -import scala.build.Logger import scala.build.Ops.* import scala.build.directives.{ HasBuildOptions, @@ -26,6 +25,7 @@ import scala.build.options.{ import scala.build.preprocessing.directives.DirectivesPreprocessingUtils.* import scala.build.preprocessing.directives.PartiallyProcessedDirectives.* import scala.build.preprocessing.directives.* +import scala.build.{Logger, Named} case class DirectivesPreprocessor( path: Either[String, os.Path], @@ -136,19 +136,22 @@ case class DirectivesPreprocessor( logger.experimentalWarning(scopedDirective.directive.toString, FeatureType.Directive) handler.handleValues(scopedDirective, logger) + def excludeNamed(key: String): String = + Named.fromKey(key).value + val handlersMap = handlers .flatMap { handler => handler.keys.flatMap(_.nameAliases).map(k => k -> handleValues(handler)) } .toMap - val unused = directives.filter(d => !handlersMap.contains(d.key)) + val unused = directives.filter(d => !handlersMap.contains(excludeNamed(d.key))) val res = directives .iterator .flatMap { case d @ StrictDirective(k, _, _, _) => - handlersMap.get(k).iterator.map(_(ScopedDirective(d, path, cwd), logger)) + handlersMap.get(excludeNamed(k)).iterator.map(_(ScopedDirective(d, path, cwd), logger)) } .toVector .flatMap { diff --git a/modules/build/src/main/scala/scala/build/preprocessing/directives/DirectivesPreprocessingUtils.scala b/modules/build/src/main/scala/scala/build/preprocessing/directives/DirectivesPreprocessingUtils.scala index e3ed9ba4da..95a9b007de 100644 --- a/modules/build/src/main/scala/scala/build/preprocessing/directives/DirectivesPreprocessingUtils.scala +++ b/modules/build/src/main/scala/scala/build/preprocessing/directives/DirectivesPreprocessingUtils.scala @@ -31,6 +31,7 @@ object DirectivesPreprocessingUtils { directives.ScalaJs.handler, directives.ScalaNative.handler, directives.ScalaVersion.handler, + directives.SourceGenerator.handler, directives.Sources.handler, directives.Tests.handler ).map(_.mapE(_.buildOptions)) diff --git a/modules/core/src/main/scala/scala/build/errors/UnnamedKeyError.scala b/modules/core/src/main/scala/scala/build/errors/UnnamedKeyError.scala new file mode 100644 index 0000000000..a5cd8078de --- /dev/null +++ b/modules/core/src/main/scala/scala/build/errors/UnnamedKeyError.scala @@ -0,0 +1,6 @@ +package scala.build.errors + +import scala.build.Position + +final class UnnamedKeyError(val key: String) + extends BuildException(s"Expected key $key to be named") diff --git a/modules/directives/src/main/scala/scala/build/directives/DirectiveValueParser.scala b/modules/directives/src/main/scala/scala/build/directives/DirectiveValueParser.scala index 866653ecaa..c9db3cbc43 100644 --- a/modules/directives/src/main/scala/scala/build/directives/DirectiveValueParser.scala +++ b/modules/directives/src/main/scala/scala/build/directives/DirectiveValueParser.scala @@ -3,17 +3,11 @@ package scala.build.directives import com.virtuslab.using_directives.custom.model.{BooleanValue, EmptyValue, StringValue, Value} import scala.build.Positioned.apply -import scala.build.errors.{ - BuildException, - CompositeBuildException, - MalformedDirectiveError, - ToolkitDirectiveMissingVersionError, - UsingDirectiveValueNumError, - UsingDirectiveWrongValueTypeError -} +import scala.build.errors.* import scala.build.preprocessing.ScopePath import scala.build.preprocessing.directives.DirectiveUtil -import scala.build.{Position, Positioned} +import scala.build.{Named, Position, Positioned} +import scala.util.NotGiven abstract class DirectiveValueParser[+T] { def parse( @@ -191,4 +185,16 @@ object DirectiveValueParser { else Left(CompositeBuildException(errors)) } + given [T](using + underlying: DirectiveValueParser[T], + // TODO: nested named directives are currently not supported + notNested: NotGiven[T <:< Named[_]] + ): DirectiveValueParser[Named[T]] = { + (key, values, scopePath, path) => + for { + named <- Right(Named.fromKey(key)) + name <- named.name.toRight(UnnamedKeyError(key)) + res <- underlying.parse(named.value, values.filter(!_.isEmpty), scopePath, path) + } yield Named(name, res) + } } diff --git a/modules/directives/src/main/scala/scala/build/errors/NotADirectoryError.scala b/modules/directives/src/main/scala/scala/build/errors/NotADirectoryError.scala new file mode 100644 index 0000000000..2d0eafdb6d --- /dev/null +++ b/modules/directives/src/main/scala/scala/build/errors/NotADirectoryError.scala @@ -0,0 +1,9 @@ +package scala.build.errors + +import scala.build.Position + +class NotADirectoryError(path: String, positions: Seq[Position]) + extends BuildException( + message = s"Expected a directory at '$path'".stripMargin, + positions = positions + ) diff --git a/modules/directives/src/main/scala/scala/build/errors/NotAFileError.scala b/modules/directives/src/main/scala/scala/build/errors/NotAFileError.scala new file mode 100644 index 0000000000..6776948da9 --- /dev/null +++ b/modules/directives/src/main/scala/scala/build/errors/NotAFileError.scala @@ -0,0 +1,9 @@ +package scala.build.errors + +import scala.build.Position + +class NotAFileError(path: String, positions: Seq[Position]) + extends BuildException( + message = s"Expected a file at '$path'".stripMargin, + positions = positions + ) diff --git a/modules/directives/src/main/scala/scala/build/preprocessing/directives/DirectiveHandler.scala b/modules/directives/src/main/scala/scala/build/preprocessing/directives/DirectiveHandler.scala index 40d782cb3c..714eb98419 100644 --- a/modules/directives/src/main/scala/scala/build/preprocessing/directives/DirectiveHandler.scala +++ b/modules/directives/src/main/scala/scala/build/preprocessing/directives/DirectiveHandler.scala @@ -4,7 +4,6 @@ import com.virtuslab.using_directives.custom.model.{EmptyValue, Value} import java.util.Locale -import scala.build.Logger import scala.build.Ops.* import scala.build.directives.* import scala.build.errors.{ @@ -16,6 +15,7 @@ import scala.build.errors.{ UsingDirectiveWrongValueTypeError } import scala.build.preprocessing.Scoped +import scala.build.{Logger, Named} import scala.cli.commands.SpecificationLevel import scala.deriving.* import scala.quoted.{_, given} @@ -105,8 +105,11 @@ object DirectiveHandler { w.init.mkString :: pascalCaseSplit(w.last :: tail) } + private def excludeNamed(s: String): String = + Named.fromKey(s).value + def normalizeName(s: String): String = { - val elems = s.split('-') + val elems = excludeNamed(s).split('-') (elems.head +: elems.tail.map(_.capitalize)).mkString } diff --git a/modules/directives/src/main/scala/scala/build/preprocessing/directives/SourceGenerator.scala b/modules/directives/src/main/scala/scala/build/preprocessing/directives/SourceGenerator.scala new file mode 100644 index 0000000000..8f9f94256a --- /dev/null +++ b/modules/directives/src/main/scala/scala/build/preprocessing/directives/SourceGenerator.scala @@ -0,0 +1,114 @@ +package scala.build.preprocessing.directives + +import java.nio.file.Paths + +import scala.build.EitherCps.{either, value} +import scala.build.Ops.* +import scala.build.directives.* +import scala.build.errors.{ + BuildException, + CompositeBuildException, + NotADirectoryError, + NotAFileError, + WrongSourcePathError +} +import scala.build.options.{ + BuildOptions, + InternalOptions, + SourceGeneratorConfig, + SourceGeneratorOptions +} +import scala.build.preprocessing.ScopePath +import scala.build.{Named, Positioned} +import scala.cli.commands.SpecificationLevel +import scala.util.Try + +@DirectiveGroupName("Source generators") +@DirectivePrefix("sourceGenerator.") +@DirectiveExamples("//> using sourceGenerator.[hello].input ${.}/in") +@DirectiveExamples("//> using sourceGenerator.[hello].output ${.}/out") +@DirectiveExamples("//> using sourceGenerator.[hello].glob *.txt") +@DirectiveExamples("//> using sourceGenerator.[hello].command python ${.}/gen/hello.py") +@DirectiveExamples("//> using sourceGenerator.[hello].unmanaged ${.}/gen/hello.py") +@DirectiveUsage( + """using sourceGenerator.[name].input + |using sourceGenerator.[name].output + |using sourceGenerator.[name].glob + |using sourceGenerator.[name].command + |using sourceGenerator.[name].unmanaged + |""".stripMargin, + """`//> using sourceGenerator.[`_name_`].input` _directory_ + | + |`//> using sourceGenerator.[`_name_`].output` _directory_ + | + |`//> using sourceGenerator.[`_name_`].glob` _glob_ + | + |`//> using sourceGenerator.[`_name_`].globs` _glob1_ _glob2_ … + | + |`//> using sourceGenerator.[`_name_`].command` _command_ + | + |`//> using sourceGenerator.[`_name_`].unmanaged` _file_ + | + |`//> using sourceGenerator.[`_name_`].unmanaged` _file1_ _file2_ … + | + |""".stripMargin +) +@DirectiveDescription("Configure source generators") +@DirectiveLevel(SpecificationLevel.EXPERIMENTAL) +final case class SourceGenerator( + command: Named[List[String]] = Named.none(Nil), + input: Named[DirectiveValueParser.WithScopePath[Option[Positioned[String]]]] = + Named.none(DirectiveValueParser.WithScopePath.empty(None)), + output: Named[DirectiveValueParser.WithScopePath[Option[Positioned[String]]]] = + Named.none(DirectiveValueParser.WithScopePath.empty(None)), + @DirectiveName("globs") + glob: Named[List[String]] = Named.none(Nil), + unmanaged: Named[DirectiveValueParser.WithScopePath[List[Positioned[String]]]] = + Named.none(DirectiveValueParser.WithScopePath.empty(Nil)) +) extends HasBuildOptions { + + private def resolve(cwd: ScopePath, s: Positioned[String]): Either[BuildException, os.Path] = + for { + root <- Directive.osRoot(cwd, s.positions.headOption) + res <- Try(os.Path(s.value, root)).toEither + .left.map(new WrongSourcePathError(s.value, _, s.positions)) + } yield res + + private def resolveDir( + path: DirectiveValueParser.WithScopePath[Option[Positioned[String]]] + ): Either[BuildException, Option[os.Path]] = + path.value.map { s => + resolve(path.scopePath, s) + .filterOrElse(os.isDir(_), new NotADirectoryError(s.value, s.positions)) + }.sequence + + private def resolveFiles( + path: DirectiveValueParser.WithScopePath[List[Positioned[String]]] + ): Either[BuildException, List[os.Path]] = + path.value.map { s => + resolve(path.scopePath, s) + .filterOrElse(os.isFile(_), new NotAFileError(s.value, s.positions)) + }.sequence + .left.map(CompositeBuildException(_)) + .map(_.toList) + + def buildOptions: Either[BuildException, BuildOptions] = either { + val configs = + Seq[Named[SourceGeneratorConfig]]( + command.map(v => SourceGeneratorConfig(command = v)), + input.map(v => SourceGeneratorConfig(inputDir = value(resolveDir(v)))), + output.map(v => SourceGeneratorConfig(outputDir = value(resolveDir(v)))), + glob.map(v => SourceGeneratorConfig(glob = v)), + unmanaged.map(v => SourceGeneratorConfig(unmanaged = value(resolveFiles(v)))) + ).flatten.toMap + + val options = + BuildOptions(sourceGeneratorOptions = SourceGeneratorOptions(configs = configs)) + + options + } +} + +object SourceGenerator { + val handler: DirectiveHandler[SourceGenerator] = DirectiveHandler.derive +} diff --git a/modules/options/src/main/scala/scala/build/Named.scala b/modules/options/src/main/scala/scala/build/Named.scala new file mode 100644 index 0000000000..af157a2e5c --- /dev/null +++ b/modules/options/src/main/scala/scala/build/Named.scala @@ -0,0 +1,34 @@ +package scala.build + +import scala.util.matching.Regex + +final case class Named[+T]( + name: Option[String], + value: T +) { + def map[U](f: T => U): Named[U] = + copy(value = f(value)) + + def entry: Option[(String, T)] = + name.map(n => n -> value) +} + +object Named { + private val NameRegex: Regex = "^\\[\\w+\\]$".r + + def apply[T](name: String, value: T): Named[T] = Named(Some(name), value) + + def none[T](value: T): Named[T] = + Named(None, value) + + given [T]: Conversion[Named[T], IterableOnce[(String, T)]] = + named => named.entry + + def fromKey(key: String): Named[String] = { + val parts = key.split('.') + val name = parts.find(NameRegex.matches) + val rest = parts.filterNot(name.contains) + + Named(name, rest.mkString(".")) + } +} diff --git a/modules/options/src/main/scala/scala/build/options/SourceGeneratorConfig.scala b/modules/options/src/main/scala/scala/build/options/SourceGeneratorConfig.scala new file mode 100644 index 0000000000..632b7f727b --- /dev/null +++ b/modules/options/src/main/scala/scala/build/options/SourceGeneratorConfig.scala @@ -0,0 +1,13 @@ +package scala.build.options + +final case class SourceGeneratorConfig( + inputDir: Option[os.Path] = None, + outputDir: Option[os.Path] = None, + unmanaged: List[os.Path] = Nil, + glob: List[String] = Nil, + command: List[String] = Nil +) + +object SourceGeneratorConfig { + implicit val monoid: ConfigMonoid[SourceGeneratorConfig] = ConfigMonoid.derive +} diff --git a/modules/options/src/main/scala/scala/build/options/SourceGeneratorOptions.scala b/modules/options/src/main/scala/scala/build/options/SourceGeneratorOptions.scala index fca0cf7967..79776eb487 100644 --- a/modules/options/src/main/scala/scala/build/options/SourceGeneratorOptions.scala +++ b/modules/options/src/main/scala/scala/build/options/SourceGeneratorOptions.scala @@ -3,7 +3,8 @@ package scala.build.options final case class SourceGeneratorOptions( useBuildInfo: Option[Boolean] = None, projectVersion: Option[String] = None, - computeVersion: Option[ComputeVersion] = None + computeVersion: Option[ComputeVersion] = None, + configs: Map[String, SourceGeneratorConfig] = Map.empty ) object SourceGeneratorOptions { diff --git a/modules/options/src/test/scala/scala/build/options/ConfigMonoidTest.scala b/modules/options/src/test/scala/scala/build/options/ConfigMonoidTest.scala index e32581f772..54576e2364 100644 --- a/modules/options/src/test/scala/scala/build/options/ConfigMonoidTest.scala +++ b/modules/options/src/test/scala/scala/build/options/ConfigMonoidTest.scala @@ -3,7 +3,8 @@ package scala.build.options case class Inner( foo: Boolean = false, bar: Seq[String] = Nil, - baz: Set[Double] = Set() + baz: Set[Double] = Set(), + qux: Map[String, Boolean] = Map() ) object Inner { @@ -54,4 +55,16 @@ class ConfigMonoidTest extends munit.FunSuite { assertEquals(Outer.monoid.orElse(outer2, outer1).name, Some("o2")) } + + test("Merging maps") { + val inner1 = Inner(qux = Map("k1" -> false, "k3" -> true)) + val inner2 = Inner(qux = Map("k2" -> true, "k3" -> false)) + + assertEquals(Map("k1" -> false, "k3" -> true), Inner.monoid.orElse(inner1, Inner()).qux) + assertEquals(Map("k2" -> true, "k3" -> false), Inner.monoid.orElse(inner2, Inner()).qux) + assertEquals( + Map("k1" -> false, "k2" -> true, "k3" -> true), + Inner.monoid.orElse(inner1, inner2).qux + ) + } } diff --git a/website/docs/reference/directives.md b/website/docs/reference/directives.md index 15ae1ab8c4..08ae30dcf4 100644 --- a/website/docs/reference/directives.md +++ b/website/docs/reference/directives.md @@ -416,6 +416,37 @@ Add Scala.js options #### Examples `//> using jsModuleKind common` +### Source generators + +Configure source generators + +`//> using sourceGenerator.[`_name_`].input` _directory_ + +`//> using sourceGenerator.[`_name_`].output` _directory_ + +`//> using sourceGenerator.[`_name_`].glob` _glob_ + +`//> using sourceGenerator.[`_name_`].globs` _glob1_ _glob2_ … + +`//> using sourceGenerator.[`_name_`].command` _command_ + +`//> using sourceGenerator.[`_name_`].unmanaged` _file_ + +`//> using sourceGenerator.[`_name_`].unmanaged` _file1_ _file2_ … + + + +#### Examples +`//> using sourceGenerator.[hello].input ${.}/in` + +`//> using sourceGenerator.[hello].output ${.}/out` + +`//> using sourceGenerator.[hello].glob *.txt` + +`//> using sourceGenerator.[hello].command python ${.}/gen/hello.py` + +`//> using sourceGenerator.[hello].unmanaged ${.}/gen/hello.py` + ### Test framework Set the test framework