Skip to content

Introduce source generator directives #3583

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
6 changes: 5 additions & 1 deletion modules/build/src/main/scala/scala/build/Build.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
32 changes: 29 additions & 3 deletions modules/build/src/main/scala/scala/build/Project.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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._
Expand All @@ -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,
Expand All @@ -65,7 +70,8 @@ final case class Project(
platform = Some(platform),
`scala` = scalaConfigOpt,
java = Some(BloopConfig.Java(javacOptions)),
resolution = resolution
resolution = resolution,
sourceGenerators = sourceGenerators
)
}

Expand Down Expand Up @@ -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)
)
}
}
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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],
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
Expand Up @@ -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.{
Expand All @@ -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}
Expand Down Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 <directory>
|using sourceGenerator.[name].output <directory>
|using sourceGenerator.[name].glob <globs>
|using sourceGenerator.[name].command <command>
|using sourceGenerator.[name].unmanaged <files>
|""".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
}
34 changes: 34 additions & 0 deletions modules/options/src/main/scala/scala/build/Named.scala
Original file line number Diff line number Diff line change
@@ -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("."))
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Loading