Skip to content

Commit

Permalink
Overhaul build file management with new build.mill/package.mill f…
Browse files Browse the repository at this point in the history
…ormat (#3426)

This PR overhauls how build files are handled, especially in subfolders.
The goal is to come up with an approach that is (1) scalable to large
builds and (2) works intuitively with how people expect things to work
and (3) plays well with IDEs (4) converges with the Scala language to
reduce the special casing we have to do.

There's a bunch of hackiness in the implementation, but the end result
is that when I load the updated `10-multi-file-build` project into
IntelliJ, navigation around the multi-file project works seamlessly
(after adding the `.mill` file association):

<img width="976" alt="Screenshot 2024-08-31 at 4 35 16 PM"
src="https://github.com/user-attachments/assets/b387a99a-eeb7-44f0-9a6e-6e6e864fb27f">

VSCode works as well, though it only understands `.scala` extensions and
will likely need to be patched to work with `.mill`

<img width="976" alt="Screenshot 2024-08-31 at 4 49 46 PM"
src="https://github.com/user-attachments/assets/c26c5eef-58d7-4b4d-8d51-c45b2f2cf4c2">


This means that to begin with, all the IDE tooling around Mill just
works, with the IDE being blissfully unaware of the nasty code
transformations and other things Mill does. From that base, we can
slowly look into removing boilerplate in an IDE/language-compliant way,
and removing the backend hackiness while keeping the user experience
mostly unchanged

Major user-facing changes:

1. `build.sc` is now `build.mill`, sub-folders can define modules in
`package.mill` files. Helper libraries live in files like `foo/bar.mill`
* Long term Mill needs our own extension because `.sc` is overloaded to
work with Ammonite/Scala-CLI scripts. That means IDEs like IntelliJ
treat them specially, in ways that are incompatible with Mill (e.g. IDEs
don't understand importing between script files). `.sc` is still
supported for migration/compatibility reasons. Editors don't currently
associate `.mill` with Mill Scala code, but it's a few clicks for the
end user and a trivial PR to send to IntelliJ and Metals to support it
* `.scala` as an extension could possibly work from a technical level,
but there will still be user-facing confusion if we want Mill to be able
to target non-Scala developers, and possibly technical confusion for
tools that can't differentiate between Mill build files and application
source files

2. All `.mill` files need a `package` declaration at the top, e.g.
`build.mill` needs `package build`, `foo/bar/qux.sc` needs `package
build.foo.bar`. I left it optional for the root `build.mill` for
migration purposes
* This is necessary for full IDE support, as editors do not understand
the "things in subfolders are automatically in packages" thing that Mill
and Ammonite did in the past. We had hacks around `import $file`, but
they never were super robust, and `import $file` is itself a hack we've
discussed getting rid of
* We can look into making package declarations optional again in future,
but for now this gives us working IDE support with minimal effort,
converging with vanilla Scala, at the cost of a little boilerplate
per-file.

3. No more `import $file`: any files with `.mill` (or `.sc`) extensions
adjacent to a `build.mill` or `package.mill` are treated as Mill files
* This simplifies file discovery considerably, v.s. the previous
approach that required multiple-passes of parsing and traversing all
imported scripts as a graph. It also aligns things more with how Scala
and other JVM projects work, where files are typically picked up by
folder regardless of imports

Major internal changes:

1. We move the handling of `RootModule` from runtime
reflection/classpath-scanning/custom-resolve-logic to code-generation
logic that "unpacks" any `object module`, `object build`, or `object foo
extends RootModule` into the enclosing wrapper class
* We can do this as a compiler plugin, which would help preserve line
numbers and avoid showing generated code, but it also makes things
terribly hard to debug when things go wrong
* This allows us to ensure `RootModule`s are handled uniformly from
Scala code and from the command line, where previously Scala code would
need to write `bar.qux.module.mytask` suffix while the CLI would just
write `bar.qux.mytask`
* By making the unpacking name-based, rather than type-based, this
removes the "what should we name the root module?" degree of freedom
that really shouldn't be necessary and ensures everyone names them
consistently

2. Replace the `package object`s previously generated for root modules
with normal `object`s named `build` or `module` (reflecting the file
names)
* `package object`s are on their way out in Scala 3
https://docs.scala-lang.org/scala3/reference/dropped-features/package-objects.html.
No concrete timeline, but good to stop using them. They also are a
common source of bugs around compilation, incremental compilation, etc.
since even their present-day implementation isn't terribly robust
* This also removes the need to refer to `build` as `build.package`
since it is now a normal object

3. Generate `final def` aliases in the generated `object`s in order to
make code references and module discovery work
* These aliases allow both Scala-code references to modules without
needing this `.build` or `.module` suffix, as well as allow the
`Resolve.scala` logic to work
* We can thus eliminate a whole bunch of gnarly code plumbing
multiple-root-modules all over the Mill codebase and gnarly
classpath-scanning code to try and identify the sub-folder root modules
and wire them up.
* These aliases should more reliably generate conflicts than overlapping
`package object`/`object`s, which seemed pretty flaky

Limitations:

* Partially migrating subfolders out of a parent `build.sc` into a child
`module.sc` no longer works. The aliases we generate for subfolder
`module.sc` files always conflicts at compile time with any
locally-defined modules

* References to helper files e.g. `foo/bar.sc` with `def qux` would get
referenced via `build_.foo.bar.qux` is kind of awkward. I expect this to
go away once we get Scala 3 support, and we can use `export` clauses to
allow referencing them via `build.foo.qux`, which is more in line with
how Scala code normally works where different files in the same folder
are all part of the same combined namespace.

* To work around `package object` weirdness e.g. needing to reference
things via `foo.bar.package`, we build our own parallel object hierarchy
via codegen and provide that to the user. This is awkward, but it looks
similar enough people shouldn't notice it, and we can look into somehow
fixing the `package object` issues upstream in scala/scala3 in future

* The `package build` prefix is kind of verbose, but it's necessary so
we have some way of reliably referencing other files by their fully
qualified names. e.g. a file in `util/package.mill` can be referenced by
`build.util.*`, but cannot be referenced by `util.*` because `import
mill._` pulls in `mill.util` which shadows it.
  • Loading branch information
lihaoyi authored Sep 3, 2024
1 parent 11d32c2 commit 9baac5d
Show file tree
Hide file tree
Showing 315 changed files with 2,064 additions and 1,304 deletions.
4 changes: 2 additions & 2 deletions bsp/worker/src/mill/bsp/worker/State.scala
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import mill.eval.Evaluator
private class State(workspaceDir: os.Path, evaluators: Seq[Evaluator], debug: String => Unit) {
lazy val bspModulesById: Map[BuildTargetIdentifier, (BspModule, Evaluator)] = {
val modules: Seq[(Module, Seq[Module], Evaluator)] = evaluators
.flatMap(ev => ev.rootModules.map(rm => (rm, JavaModuleUtils.transitiveModules(rm), ev)))
.map(ev => (ev.rootModule, JavaModuleUtils.transitiveModules(ev.rootModule), ev))

val map = modules
.flatMap { case (rootModule, otherModules, eval) =>
Expand All @@ -30,7 +30,7 @@ private class State(workspaceDir: os.Path, evaluators: Seq[Evaluator], debug: St
map
}

lazy val rootModules: Seq[mill.define.BaseModule] = evaluators.flatMap(_.rootModules)
lazy val rootModules: Seq[mill.define.BaseModule] = evaluators.map(_.rootModule)

lazy val bspIdByModule: Map[BspModule, BuildTargetIdentifier] =
bspModulesById.view.mapValues(_._1).map(_.swap).toMap
Expand Down
90 changes: 51 additions & 39 deletions build.sc
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ object Deps {
val osLib = ivy"com.lihaoyi::os-lib:0.10.5"
val pprint = ivy"com.lihaoyi::pprint:0.9.0"
val mainargs = ivy"com.lihaoyi::mainargs:0.7.2"
val millModuledefsVersion = "0.10.9"
val millModuledefsVersion = "0.11.0-M2"
val millModuledefsString = s"com.lihaoyi::mill-moduledefs:${millModuledefsVersion}"
val millModuledefs = ivy"${millModuledefsString}"
val millModuledefsPlugin =
Expand Down Expand Up @@ -1234,6 +1234,7 @@ object example extends Module {
object tasks extends Cross[ExampleCrossModule](listIn(millSourcePath / "tasks"))
object modules extends Cross[ExampleCrossModule](listIn(millSourcePath / "modules"))
object cross extends Cross[ExampleCrossModule](listIn(millSourcePath / "cross"))
object large extends Cross[ExampleCrossModule](listIn(millSourcePath / "large"))
}

object extending extends Module {
Expand All @@ -1251,47 +1252,57 @@ object example extends Module {
case "testing" => scalalib.testing
case "web" => scalalib.web
}
def testRepoRoot = T {
os.copy.over(super.testRepoRoot().path, T.dest)
for (lines <- buildScLines()) os.write.over(T.dest / "build.sc", lines.mkString("\n"))
PathRef(T.dest)
val upstreamOpt = upstreamCross(
this.millModuleSegments.parts.dropRight(1).last
).valuesToModules.get(List(crossValue))

def testRepoRoot = upstreamOpt match {
case None => T{ super.testRepoRoot() }
case Some(upstream) => T{
os.copy.over(super.testRepoRoot().path, T.dest)
val upstreamRoot = upstream.testRepoRoot().path
val suffix = Seq("build.mill", "build.mill").find(s => os.exists(upstreamRoot / s)).head
for(lines <- buildScLines()) {
os.write.over(T.dest / suffix, lines.mkString("\n"))
}
PathRef(T.dest)
}
}
def buildScLines =
upstreamCross(
this.millModuleSegments.parts.dropRight(1).last
).valuesToModules.get(List(crossValue)) match {
case None => T { None }
case Some(upstream) => T {
Some {
val upstreamLines = os.read.lines(upstream.testRepoRoot().path / "build.sc")
val lines = os.read.lines(super.testRepoRoot().path / "build.sc")

import collection.mutable
val groupedLines = mutable.Map.empty[String, mutable.Buffer[String]]
var current = Option.empty[String]
lines.foreach {
case s"//// SNIPPET:$name" =>
current = Some(name)
groupedLines(name) = mutable.Buffer()
case s => current.foreach(groupedLines(_).append(s))
}
def buildScLines = upstreamOpt match {
case None => T { None }
case Some(upstream) => T {
Some {
val upstreamRoot = upstream.testRepoRoot().path
val suffix = Seq("build.sc", "build.mill").find(s => os.exists(upstreamRoot / s)).head
val upstreamLines = os.read.lines(upstream.testRepoRoot().path / suffix)
val lines = os.read.lines(super.testRepoRoot().path / suffix)

import collection.mutable
val groupedLines = mutable.Map.empty[String, mutable.Buffer[String]]
var current = Option.empty[String]
lines.foreach {
case s"//// SNIPPET:$name" =>
current = Some(name)
groupedLines(name) = mutable.Buffer()
case s => current.foreach(groupedLines(_).append(s))
}

current = None
upstreamLines.flatMap {
case s"//// SNIPPET:$name" =>
if (name != "END") {
current = Some(name)
groupedLines(name)
} else {
current = None
Nil
}

case s => if (current.nonEmpty) None else Some(s)
current = None
upstreamLines.flatMap {
case s"//// SNIPPET:$name" =>
if (name != "END") {
current = Some(name)
groupedLines(name)
} else {
current = None
Nil
}
}

case s => if (current.nonEmpty) None else Some(s)
}
}
}
}
}

trait ExampleCrossModule extends IntegrationTestCrossModule {
Expand All @@ -1307,7 +1318,7 @@ object example extends Module {
)

/**
* Parses a `build.sc` for specific comments and return the split-by-type content
* Parses a `build.mill` for specific comments and return the split-by-type content
*/
def parsed: T[Seq[(String, String)]] = T {
mill.testkit.ExampleParser(testRepoRoot().path)
Expand Down Expand Up @@ -1336,7 +1347,7 @@ object example extends Module {
val exampleDashed = examplePath.segments.mkString("-")
val download = s"{mill-download-url}/$label-$exampleDashed.zip[download]"
val browse = s"{mill-example-url}/$examplePath[browse]"
s".build.sc ($download, $browse)"
s".build.mill ($download, $browse)"
}
seenCode = true
s"""
Expand Down Expand Up @@ -1545,6 +1556,7 @@ object runner extends MillPublishScalaModule {
def skipPreviousVersions: T[Seq[String]] = Seq("0.11.0-M7")

object linenumbers extends MillPublishScalaModule {
def moduleDeps = Seq(main.client)
def scalaVersion = Deps.scalaVersion
def ivyDeps = Agg(Deps.scalaCompiler(scalaVersion()))
}
Expand Down
13 changes: 0 additions & 13 deletions ci/mill-bootstrap.patch
Original file line number Diff line number Diff line change
@@ -1,13 +0,0 @@
diff --git a/build.sc b/build.sc
index 526ebaa9cf..616be491f9 100644
--- a/build.sc
+++ b/build.sc
@@ -1968,7 +1968,7 @@ def uploadToGithub(authKey: String) = T.command {

private def resolveTasks[T](taskNames: String*): Seq[NamedTask[T]] = {
mill.resolve.Resolve.Tasks.resolve(
- build,
+ build.`package`,
taskNames,
SelectMode.Separated
).map(x => x.asInstanceOf[Seq[mill.define.NamedTask[T]]]).getOrElse(???)
1 change: 0 additions & 1 deletion ci/upload.sc
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
#!/usr/bin/env amm

import scalaj.http._
import mainargs.main
Expand Down
3 changes: 2 additions & 1 deletion contrib/bloop/readme.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ It generate correct bloop config for any `JavaModule`, `ScalaModule`,
You can mix-in the `Bloop.Module` trait with any JavaModule to quickly access
the deserialised configuration for that particular module:

.`build.sc`
.`build.mill`
[source,scala]
----
package build
import $ivy.`com.lihaoyi::mill-contrib-bloop:`
import mill._
Expand Down
2 changes: 1 addition & 1 deletion contrib/bloop/src/mill/contrib/bloop/BloopImpl.scala
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ class BloopImpl(evs: () => Seq[Evaluator], wd: os.Path) extends ExternalModule {
val evals = evs()
evals.flatMap { eval =>
if (eval != null)
eval.rootModules.flatMap(JavaModuleUtils.transitiveModules(_, accept))
JavaModuleUtils.transitiveModules(eval.rootModule, accept)
.collect { case jm: JavaModule => jm }
else
Seq.empty
Expand Down
3 changes: 2 additions & 1 deletion contrib/buildinfo/readme.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ To declare a module that uses BuildInfo you must extend the `mill.contrib.buildi

Quickstart:

.`build.sc`
.`build.mill`
[source,scala]
----
package build
import $ivy.`com.lihaoyi::mill-contrib-buildinfo:`
import mill.contrib.buildinfo.BuildInfo
Expand Down
3 changes: 2 additions & 1 deletion contrib/docker/readme.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ Requires the docker CLI to be installed.

In the simplest configuration just extend `DockerModule` and declare a `DockerConfig` object.

.`build.sc`
.`build.mill`
[source,scala]
----
package build
import mill._, scalalib._
import $ivy.`com.lihaoyi::mill-contrib-docker:$MILL_VERSION`
Expand Down
3 changes: 2 additions & 1 deletion contrib/flyway/readme.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ The flyway module currently supports the most common flyway use cases with file

Configure flyway by overriding settings in your module. For example

.`build.sc`
.`build.mill`
[source,scala]
----
package build
import mill._, scalalib._
import $ivy.`com.lihaoyi::mill-contrib-flyway:`
Expand Down
10 changes: 6 additions & 4 deletions contrib/gitlab/readme.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ gitlab CI/CD pipeline.

Most trivial publish config is:

.`build.sc`
.`build.mill`
[source,scala]
----
package build
import mill._, scalalib._, mill.scalalib.publish._
import $ivy.`com.lihaoyi::mill-contrib-gitlab:`
import mill.contrib.gitlab._
Expand Down Expand Up @@ -54,7 +55,7 @@ personal access token, then deploy token and lastly ci job token. Default search
. Workspace file `.gitlab/deploy-token`
. Environment variable `CI_JOB_TOKEN`

Items 1-4 are *personal access tokens*, 5-8 *deploy tokens* and 9 is *job token*. Workspace in items 4 and 8 refers to directory where `build.sc` is (`T.workspace` in mill terms).
Items 1-4 are *personal access tokens*, 5-8 *deploy tokens* and 9 is *job token*. Workspace in items 4 and 8 refers to directory where `build.mill` is (`T.workspace` in mill terms).

Because contents of `$CI_JOB_TOKEN` is checked publishing should just work when run in Gitlab CI/CD pipeline. If you want something else than default lookup configuration can be overridden. There are different ways of configuring token resolving.

Expand All @@ -64,7 +65,7 @@ Because contents of `$CI_JOB_TOKEN` is checked publishing should just work when

If you want to change environment variable names, property names of paths where plugin looks for token. It can be done by overriding their respective values in `GitlabTokenLookup`. For example:

.`build.sc`
.`build.mill`
[source,scala]
----
override def tokenLookup: GitlabTokenLookup = new GitlabTokenLookup {
Expand All @@ -81,9 +82,10 @@ This still keeps the default search order, but allows changes to places where to

If the original search order is too wide, or you would like to add places to look, you can override the `tokenSearchOrder`. Example below ignores default search order and adds five places to search from.

.`build.sc`
.`build.mill`
[source,scala]
----
package build
// Personal, Env, Deploy etc types
import mill.contrib.gitlab.GitlabTokenLookup._
Expand Down
3 changes: 2 additions & 1 deletion contrib/jmh/readme.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ You can use `JmhModule` to integrate JMH testing with Mill.

Example configuration:

.`build.sc`
.`build.mill`
[source,scala]
----
package build
import mill._, scalalib._
import $ivy.`com.lihaoyi::mill-contrib-jmh:`
Expand Down
Loading

0 comments on commit 9baac5d

Please sign in to comment.