Skip to content

Commit 88ab51c

Browse files
committed
Add InputsComposer for reading .toml file and creating a sequence of inputs for further processing
1 parent ef8e672 commit 88ab51c

File tree

9 files changed

+274
-27
lines changed

9 files changed

+274
-27
lines changed

build.sc

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import $file.project.settings, settings.{
1313
ScalaCliSbtModule,
1414
ScalaCliScalafixModule,
1515
localRepoResourcePath,
16+
moduleConfigFileName,
1617
platformExecutableJarExtension,
1718
workspaceDirName,
1819
projectFileName,
@@ -474,6 +475,7 @@ trait Core extends ScalaCliCrossSbtModule
474475
| def workspaceDirName = "$workspaceDirName"
475476
| def projectFileName = "$projectFileName"
476477
| def jvmPropertiesFileName = "$jvmPropertiesFileName"
478+
| def moduleConfigFileName = "$moduleConfigFileName"
477479
| def scalacArgumentsFileName = "scalac.args.txt"
478480
| def maxScalacArgumentsCount = 5000
479481
|
@@ -701,7 +703,8 @@ trait Build extends ScalaCliCrossSbtModule
701703
Deps.scalaJsEnvNodeJs,
702704
Deps.scalaJsTestAdapter,
703705
Deps.swoval,
704-
Deps.zipInputStream
706+
Deps.zipInputStream,
707+
Deps.tomlScala
705708
)
706709

707710
def repositoriesTask =
@@ -738,6 +741,8 @@ trait Build extends ScalaCliCrossSbtModule
738741
| def defaultScalaVersion = "${Scala.defaultUser}"
739742
| def defaultScala212Version = "${Scala.scala212}"
740743
| def defaultScala213Version = "${Scala.scala213}"
744+
|
745+
| def moduleConfigFileName = "$moduleConfigFileName"
741746
|}
742747
|""".stripMargin
743748
if (!os.isFile(dest) || os.read(dest) != code)

modules/build-macros/src/main/scala/scala/build/EitherCps.scala

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ object EitherCps:
1313
case Left(e) => throw EitherFailure(e, cps)
1414
case Right(v) => v
1515

16+
def failure[E](using
17+
cps: EitherCps[_ >: E]
18+
)(e: E) = // Adding a context bounds breaks incremental compilation
19+
throw EitherFailure(e, cps)
20+
1621
final class Helper[E]():
1722
def apply[V](op: EitherCps[E] ?=> V): Either[E, V] =
1823
val cps = new EitherCps[E]
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
package scala.build.input
2+
3+
import toml.Value
4+
import toml.Value.*
5+
6+
import collection.mutable
7+
import scala.build.EitherCps.*
8+
import scala.build.EitherSequence
9+
import scala.build.internal.Constants
10+
import scala.build.errors.{BuildException, CompositeBuildException, ModuleConfigurationError}
11+
import scala.build.options.{BuildOptions, ModuleOptions}
12+
import scala.build.bsp.buildtargets.ProjectName
13+
14+
object InputsComposer {
15+
16+
private object Keys {
17+
val modules = "modules"
18+
val roots = "roots"
19+
val dependsOn = "dependsOn"
20+
}
21+
22+
private case class ModuleDefinition(
23+
name: String,
24+
roots: Seq[String],
25+
dependsOn: Seq[String] = Nil
26+
)
27+
}
28+
29+
final case class InputsComposer(
30+
args: Seq[String],
31+
cwd: os.Path,
32+
inputsFromArgs: Seq[String] => Either[BuildException, ModuleInputs],
33+
allowForbiddenFeatures: Boolean
34+
) {
35+
import InputsComposer.*
36+
37+
/** Inputs with no dependencies coming only from args */
38+
def basicInputs = for (inputs <- inputsFromArgs(args)) yield Seq(inputs)
39+
40+
def getModuleInputs: Either[BuildException, Seq[ModuleInputs]] =
41+
if allowForbiddenFeatures then
42+
findModuleConfig match {
43+
case Right(Some(path)) =>
44+
val configText = os.read(path)
45+
for {
46+
table <-
47+
toml.Toml.parse(configText).left.map(e =>
48+
ModuleConfigurationError(e._2)
49+
) // TODO use the Address value returned to show better errors
50+
modules <- readAllModules(table.values.get(Keys.modules))
51+
_ <- checkForCycles(modules)
52+
moduleInputs <- fromModuleDefinitions(modules)
53+
} yield moduleInputs
54+
case Right(None) => basicInputs
55+
case Left(err) => Left(err)
56+
}
57+
else basicInputs
58+
59+
// private def readScalaVersion(value: Value): Either[String, String] = value match {
60+
// case Str(version) => Right(version)
61+
// case _ => Left("scalaVersion must be a string")
62+
// }
63+
64+
// TODO errors on corner cases
65+
private def findModuleConfig: Either[ModuleConfigurationError, Option[os.Path]] = {
66+
def moduleConfigDirectlyFromArgs = {
67+
val moduleConfigPathOpt = args
68+
.map(arg => os.Path(arg, cwd))
69+
.find(_.endsWith(os.RelPath(Constants.moduleConfigFileName)))
70+
71+
moduleConfigPathOpt match {
72+
case Some(path) if os.exists(path) => Right(Some(path))
73+
case Some(path) => Left(ModuleConfigurationError(
74+
s"""File does not exist:
75+
| - $path
76+
|""".stripMargin
77+
))
78+
case None => Right(None)
79+
}
80+
}
81+
82+
def moduleConfigFromCwd =
83+
Right(os.walk(cwd).find(p => p.endsWith(os.RelPath(Constants.moduleConfigFileName))))
84+
85+
for {
86+
fromArgs <- moduleConfigDirectlyFromArgs
87+
fromCwd <- moduleConfigFromCwd
88+
} yield fromArgs.orElse(fromCwd)
89+
}
90+
91+
// TODO Check for module dependencies that do not exist
92+
private def readAllModules(modules: Option[Value])
93+
: Either[BuildException, Seq[ModuleDefinition]] = modules match {
94+
case Some(Tbl(values)) => EitherSequence.sequence {
95+
values.toSeq.map(readModule)
96+
}.left.map(CompositeBuildException.apply)
97+
case _ => Left(ModuleConfigurationError(s"$modules must exist and must be a table"))
98+
}
99+
100+
private def readModule(
101+
key: String,
102+
value: Value
103+
): Either[ModuleConfigurationError, ModuleDefinition] =
104+
value match
105+
case Tbl(values) =>
106+
val maybeRoots = values.get(Keys.roots).map {
107+
case Str(value) => Right(Seq(value))
108+
case Arr(values) => EitherSequence.sequence {
109+
values.map {
110+
case Str(value) => Right(value)
111+
case _ => Left(())
112+
}
113+
}.left.map(_ => ())
114+
case _ => Left(())
115+
}.getOrElse(Right(Seq(key)))
116+
.left.map(_ =>
117+
ModuleConfigurationError(
118+
s"${Keys.modules}.$key.${Keys.roots} must be a string or a list of strings"
119+
)
120+
)
121+
122+
val maybeDependsOn = values.get(Keys.dependsOn).map {
123+
case Arr(values) =>
124+
EitherSequence.sequence {
125+
values.map {
126+
case Str(value) => Right(value)
127+
case _ => Left(())
128+
}
129+
}.left.map(_ => ())
130+
case _ => Left(())
131+
}.getOrElse(Right(Nil))
132+
.left.map(_ =>
133+
ModuleConfigurationError(
134+
s"${Keys.modules}.$key.${Keys.dependsOn} must be a list of strings"
135+
)
136+
)
137+
138+
for {
139+
roots <- maybeRoots
140+
dependsOn <- maybeDependsOn
141+
} yield ModuleDefinition(key, roots, dependsOn)
142+
143+
case _ => Left(ModuleConfigurationError(s"${Keys.modules}.$key must be a table"))
144+
145+
private def checkForCycles(modules: Seq[ModuleDefinition])
146+
: Either[ModuleConfigurationError, Unit] = either {
147+
val lookup = Map.from(modules.map(module => module.name -> module))
148+
val seen = mutable.Set.empty[String]
149+
val visiting = mutable.Set.empty[String]
150+
151+
def visit(node: ModuleDefinition, from: ModuleDefinition | Null): Unit =
152+
if visiting.contains(node.name) then
153+
val fromName = Option(from).map(_.name).getOrElse("<unknown>")
154+
val onMessage = if fromName == node.name then "itself." else s"module '${node.name}'."
155+
failure(ModuleConfigurationError(
156+
s"module graph is invalid: module '$fromName' has a cyclic dependency on $onMessage"
157+
))
158+
else if !seen.contains(node.name) then
159+
visiting.add(node.name)
160+
for dep <- node.dependsOn do
161+
lookup.get(dep) match
162+
case Some(module) => visit(module, node)
163+
case _ => failure(ModuleConfigurationError(
164+
s"module '${node.name}' depends on '$dep' which does not exist."
165+
)) // TODO handle in module parsing for better error
166+
visiting.remove(node.name)
167+
seen.addOne(node.name)
168+
else ()
169+
end visit
170+
171+
Right(lookup.values.foreach(visit(_, null)))
172+
}
173+
174+
/** Create module inputs using a supplied function [[inputsFromArgs]], link them with their module
175+
* dependencies' names
176+
*
177+
* @return
178+
* a list of module inputs for the extracted modules
179+
*/
180+
private def fromModuleDefinitions(modules: Seq[ModuleDefinition])
181+
: Either[BuildException, Seq[ModuleInputs]] = either {
182+
val moduleInputsInfo = modules.map(m => m -> value(inputsFromArgs(m.roots)))
183+
184+
val projectNameMap: Map[String, ProjectName] =
185+
moduleInputsInfo.map((moduleDef, inputs) => moduleDef.name -> inputs.projectName).toMap
186+
187+
val moduleInputs = moduleInputsInfo.map { (moduleDef, inputs) =>
188+
val moduleDeps: Seq[ProjectName] = moduleDef.dependsOn.map(projectNameMap)
189+
190+
inputs.dependsOn(moduleDeps)
191+
}
192+
193+
moduleInputs
194+
}
195+
}

modules/build/src/main/scala/scala/build/input/ModuleInputs.scala

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,12 @@ final case class ModuleInputs(
2525
mayAppendHash: Boolean,
2626
workspaceOrigin: Option[WorkspaceOrigin],
2727
enableMarkdown: Boolean,
28-
allowRestrictedFeatures: Boolean
28+
allowRestrictedFeatures: Boolean,
29+
moduleDependencies: Seq[ProjectName]
2930
) {
3031

32+
def dependsOn(modules: Seq[ProjectName]) = copy(moduleDependencies = modules)
33+
3134
def isEmpty: Boolean = elements.isEmpty
3235

3336
def singleFiles(): Seq[SingleFile] =
@@ -158,7 +161,8 @@ object ModuleInputs {
158161
mayAppendHash = needsHash,
159162
workspaceOrigin = Some(workspaceOrigin),
160163
enableMarkdown = enableMarkdown,
161-
allowRestrictedFeatures = allowRestrictedFeatures
164+
allowRestrictedFeatures = allowRestrictedFeatures,
165+
moduleDependencies = Nil
162166
)
163167
}
164168

@@ -471,11 +475,12 @@ object ModuleInputs {
471475
mayAppendHash = true,
472476
workspaceOrigin = None,
473477
enableMarkdown = enableMarkdown,
474-
allowRestrictedFeatures = false
478+
allowRestrictedFeatures = false,
479+
moduleDependencies = Nil
475480
)
476481

477482
def empty(projectName: String): ModuleInputs =
478-
ModuleInputs(Nil, None, os.pwd, projectName, false, None, true, false)
483+
ModuleInputs(Nil, None, os.pwd, projectName, false, None, true, false, Nil)
479484

480485
def baseName(p: os.Path) = if (p == os.root) "" else p.baseName
481486
}

modules/build/src/main/scala/scala/build/preprocessing/JavaPreprocessor.scala

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@ import java.nio.charset.StandardCharsets
88
import scala.build.EitherCps.{either, value}
99
import scala.build.Logger
1010
import scala.build.errors.BuildException
11-
import scala.build.input.{JavaFile, ModuleInputs, ScalaCliInvokeData, SingleElement, VirtualJavaFile}
11+
import scala.build.input.{
12+
JavaFile,
13+
ModuleInputs,
14+
ScalaCliInvokeData,
15+
SingleElement,
16+
VirtualJavaFile
17+
}
1218
import scala.build.internal.JavaParserProxyMaker
1319
import scala.build.options.{
1420
BuildOptions,

modules/build/src/main/scala/scala/build/preprocessing/MarkdownPreprocessor.scala

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@ import java.nio.charset.StandardCharsets
55
import scala.build.EitherCps.{either, value}
66
import scala.build.Logger
77
import scala.build.errors.BuildException
8-
import scala.build.input.{MarkdownFile, ModuleInputs, ScalaCliInvokeData, SingleElement, VirtualMarkdownFile}
8+
import scala.build.input.{
9+
MarkdownFile,
10+
ModuleInputs,
11+
ScalaCliInvokeData,
12+
SingleElement,
13+
VirtualMarkdownFile
14+
}
915
import scala.build.internal.markdown.{MarkdownCodeBlock, MarkdownCodeWrapper}
1016
import scala.build.internal.{AmmUtil, Name}
1117
import scala.build.options.{BuildOptions, BuildRequirements, SuppressWarningOptions}

modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,19 @@ import dependency.parser.DependencyParser
1616
import java.io.{File, InputStream}
1717
import java.nio.file.Paths
1818
import java.util.concurrent.atomic.AtomicBoolean
19-
2019
import scala.build.EitherCps.{either, value}
2120
import scala.build.Ops.EitherOptOps
2221
import scala.build.*
2322
import scala.build.compiler.{BloopCompilerMaker, ScalaCompilerMaker, SimpleScalaCompilerMaker}
2423
import scala.build.directives.DirectiveDescription
2524
import scala.build.errors.{AmbiguousPlatformError, BuildException, ConfigDbException, Severity}
26-
import scala.build.input.{Element, ModuleInputs, ResourceDirectory, ScalaCliInvokeData}
25+
import scala.build.input.{
26+
Element,
27+
InputsComposer,
28+
ModuleInputs,
29+
ResourceDirectory,
30+
ScalaCliInvokeData
31+
}
2732
import scala.build.interactive.Interactive
2833
import scala.build.interactive.Interactive.{InteractiveAsk, InteractiveNop}
2934
import scala.build.internal.util.WarningMessages
@@ -623,27 +628,42 @@ final case class SharedOptions(
623628

624629
lazy val coursierCache = coursier.coursierCache(logging.logger.coursierLogger(""))
625630

626-
def inputs(
631+
private def moduleInputs(
627632
args: Seq[String],
628633
defaultInputs: () => Option[ModuleInputs] = () => ModuleInputs.default()
629-
)(using ScalaCliInvokeData): Either[BuildException, ModuleInputs] =
630-
SharedOptions.inputs(
634+
)(using ScalaCliInvokeData) = SharedOptions.inputs(
635+
args,
636+
defaultInputs,
637+
resourceDirs,
638+
Directories.directories,
639+
logger = logger,
640+
coursierCache,
641+
workspace.forcedWorkspaceOpt,
642+
input.defaultForbiddenDirectories,
643+
input.forbid,
644+
scriptSnippetList = allScriptSnippets,
645+
scalaSnippetList = allScalaSnippets,
646+
javaSnippetList = allJavaSnippets,
647+
markdownSnippetList = allMarkdownSnippets,
648+
enableMarkdown = markdown.enableMarkdown,
649+
extraClasspathWasPassed = extraClasspathWasPassed
650+
)
651+
652+
def composeInputs(
653+
args: Seq[String],
654+
defaultInputs: () => Option[ModuleInputs] = () => ModuleInputs.default()
655+
)(using ScalaCliInvokeData): Either[BuildException, Seq[ModuleInputs]] =
656+
InputsComposer(
631657
args,
632-
defaultInputs,
633-
resourceDirs,
634-
Directories.directories,
635-
logger = logger,
636-
coursierCache,
637-
workspace.forcedWorkspaceOpt,
638-
input.defaultForbiddenDirectories,
639-
input.forbid,
640-
scriptSnippetList = allScriptSnippets,
641-
scalaSnippetList = allScalaSnippets,
642-
javaSnippetList = allJavaSnippets,
643-
markdownSnippetList = allMarkdownSnippets,
644-
enableMarkdown = markdown.enableMarkdown,
645-
extraClasspathWasPassed = extraClasspathWasPassed
646-
)
658+
Os.pwd,
659+
moduleInputs(_, defaultInputs),
660+
ScalaCli.allowRestrictedFeatures
661+
).getModuleInputs
662+
663+
def inputs(
664+
args: Seq[String],
665+
defaultInputs: () => Option[ModuleInputs] = () => ModuleInputs.default()
666+
)(using ScalaCliInvokeData) = moduleInputs(args, defaultInputs)
647667

648668
def allScriptSnippets: List[String] = snippet.scriptSnippet ++ snippet.executeScript
649669
def allScalaSnippets: List[String] = snippet.scalaSnippet ++ snippet.executeScala
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
package scala.build.errors
2+
3+
final class ModuleConfigurationError(message: String)
4+
extends BuildException(message)

0 commit comments

Comments
 (0)