Skip to content

Commit 60b527d

Browse files
committed
Add InputsComposer for reading .toml file and creating a sequence of inputs for further processing
1 parent b843d49 commit 60b527d

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,
@@ -467,6 +468,7 @@ trait Core extends ScalaCliCrossSbtModule
467468
| def workspaceDirName = "$workspaceDirName"
468469
| def projectFileName = "$projectFileName"
469470
| def jvmPropertiesFileName = "$jvmPropertiesFileName"
471+
| def moduleConfigFileName = "$moduleConfigFileName"
470472
| def scalacArgumentsFileName = "scalac.args.txt"
471473
| def maxScalacArgumentsCount = 5000
472474
|
@@ -687,7 +689,8 @@ trait Build extends ScalaCliCrossSbtModule
687689
Deps.scalaJsEnvNodeJs,
688690
Deps.scalaJsTestAdapter,
689691
Deps.swoval,
690-
Deps.zipInputStream
692+
Deps.zipInputStream,
693+
Deps.tomlScala
691694
)
692695

693696
def repositoriesTask =
@@ -724,6 +727,8 @@ trait Build extends ScalaCliCrossSbtModule
724727
| def defaultScalaVersion = "${Scala.defaultUser}"
725728
| def defaultScala212Version = "${Scala.scala212}"
726729
| def defaultScala213Version = "${Scala.scala213}"
730+
|
731+
| def moduleConfigFileName = "$moduleConfigFileName"
727732
|}
728733
|""".stripMargin
729734
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.ConsoleUtils.ScalaCliConsole
@@ -600,27 +605,42 @@ final case class SharedOptions(
600605

601606
lazy val coursierCache = coursier.coursierCache(logging.logger.coursierLogger(""))
602607

603-
def inputs(
608+
private def moduleInputs(
604609
args: Seq[String],
605610
defaultInputs: () => Option[ModuleInputs] = () => ModuleInputs.default()
606-
)(using ScalaCliInvokeData): Either[BuildException, ModuleInputs] =
607-
SharedOptions.inputs(
611+
)(using ScalaCliInvokeData) = SharedOptions.inputs(
612+
args,
613+
defaultInputs,
614+
resourceDirs,
615+
Directories.directories,
616+
logger = logger,
617+
coursierCache,
618+
workspace.forcedWorkspaceOpt,
619+
input.defaultForbiddenDirectories,
620+
input.forbid,
621+
scriptSnippetList = allScriptSnippets,
622+
scalaSnippetList = allScalaSnippets,
623+
javaSnippetList = allJavaSnippets,
624+
markdownSnippetList = allMarkdownSnippets,
625+
enableMarkdown = markdown.enableMarkdown,
626+
extraClasspathWasPassed = extraClasspathWasPassed
627+
)
628+
629+
def composeInputs(
630+
args: Seq[String],
631+
defaultInputs: () => Option[ModuleInputs] = () => ModuleInputs.default()
632+
)(using ScalaCliInvokeData): Either[BuildException, Seq[ModuleInputs]] =
633+
InputsComposer(
608634
args,
609-
defaultInputs,
610-
resourceDirs,
611-
Directories.directories,
612-
logger = logger,
613-
coursierCache,
614-
workspace.forcedWorkspaceOpt,
615-
input.defaultForbiddenDirectories,
616-
input.forbid,
617-
scriptSnippetList = allScriptSnippets,
618-
scalaSnippetList = allScalaSnippets,
619-
javaSnippetList = allJavaSnippets,
620-
markdownSnippetList = allMarkdownSnippets,
621-
enableMarkdown = markdown.enableMarkdown,
622-
extraClasspathWasPassed = extraClasspathWasPassed
623-
)
635+
Os.pwd,
636+
moduleInputs(_, defaultInputs),
637+
ScalaCli.allowRestrictedFeatures
638+
).getModuleInputs
639+
640+
def inputs(
641+
args: Seq[String],
642+
defaultInputs: () => Option[ModuleInputs] = () => ModuleInputs.default()
643+
)(using ScalaCliInvokeData) = moduleInputs(args, defaultInputs)
624644

625645
def allScriptSnippets: List[String] = snippet.scriptSnippet ++ snippet.executeScript
626646
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)