Skip to content

Commit a993068

Browse files
Simple YAML-backed modules and single-file script modules, second iteration (#5951)
This PR implements a way to define "Simple Modules" based on `mill.yaml` files or single `.java`/`.scala`/`.kt` files with `//|` build headers. This should make Mill more appealing for small projects, where the use of a `build.mill` file adds significant boilerplate and complexity. Most small projects need minimal customization of the build, and so the full Scala `.mill` syntax provides no value over a more lightweight config-only approach. This PR also allows interop between simple YAML modules and custom module classes written in `mill-build/src/`, to allow a gradual transition to the more flexible programmatic configuration ## Simple Module with `build.mill.yaml` `mill.yaml` ```yaml extends: ["mill.javalib.JavaModule"] mvnDeps: - "net.sourceforge.argparse4j:argparse4j:0.9.0" - "org.thymeleaf:thymeleaf:3.1.1.RELEASE" ``` `test/mill.yaml` ```yamlextends: "mill.javalib.JavaModule.Junit4" moduleDeps: ["build"] mvnDeps: - "com.google.guava:guava:33.3.0-jre" ``` ```console > ./mill run --text hello <h1>hello</h1> > ./mill .:run --text hello # `.` for explicit root module, `:` as new external module separator > ./mill test ... + foo.FooTests...simple ... "<h1>hello</h1>" + foo.FooTests...escaping ... "<h1>&lt;hello&gt;</h1>" > ./mill test:testForked # explicit task ``` ## Single-File Module with `.java`, `.scala`, or `.kt` `Foo.scala` ```scala //| mvnDeps: //| - "com.lihaoyi::scalatags:0.13.1" //| - "com.lihaoyi::mainargs:0.7.6" import scalatags.Text.all.* import mainargs.{main, ParserForMethods} object Foo { def generateHtml(text: String) = { h1(text).toString } @main def main(text: String) = { println(generateHtml(text)) } def main(args: Array[String]): Unit = ParserForMethods(this).runOrExit(args) } ``` ```console > ./mill Foo.scala --text hello compiling 1 Scala source to... <h1>hello</h1> > ./mill Foo.scala:run --text hello <h1>hello</h1> > ./mill show Foo.scala:assembly # show the output of the assembly task ".../out/Foo.scala/assembly.dest/out.jar" > java -jar ./out/Foo.scala/assembly.dest/out.jar --text hello <h1>hello</h1> ``` ## Implementation * Mill considers `build.mill.yaml` and `package.mill.yaml` files similarly to `build.mill` and `package.mill` * Mill code-generates Scala code for a `package` object for each `build.mill.yaml`/`package.mill.yaml` file: `extends` becomes the inheritance list, `moduleDeps` becomes `moduleDeps`, and everything else becomes `def key = Task.Literal("""value""")` * A bunch of the `upickle.default.ReadWriter`s have been made more lenient to allow convenient YAML/JSON input of their values: `PathRef`s, the various `mill.javalib.publish.*` types, * `.mill.yaml` and `.mill` can depend on each other and inter-operate, since in the end they all expand into Scala code * Single-file scripts continue to be modelled as `ExternalModule`s instantiated during the `Resolve` phase. These cannot be depended upon by `.mill.yaml`/`.mill` modules Supersedes #5836 ### Advantages 1. This allows use of normal `trait` modules without needing to package them up into special `class`es, so it can be applied much more generally to dozens of `trait`s available in the Mill libraries 2. This allows multiple inheritance, e.g. `extends: [mill.javalib.JavaModule, mill.javalib.PublishModule]` 3. Modules defined in `build/package.mill.yaml` can interop much more cleanly with `build/package.mill` files, including type-safe references in either direction 4. We can handle things like requiring abstract methods to be defined in the YAML, since the YAML keys get code-gened into method implementations ### Disadvantages 1. Generated code can slow down compiles, whereas previous reflective instantiation was basically instant 2. Compile errors in generated code may be confusing to the user, since they didn't write the generated code themselves 3. Script files will still need to be instantiated reflectively from `class`es, as the ad-hoc nature of scripts means they may live in arbitrary files that are only discovered pretty late (e.g in the middle of execution of a `show` command that resolves things) that cannot be easily discovered during Mill's `walkBuildFiles` step 4. Script files cannot use test frameworks for now, though they can be tested via normal downstream scripts with `main` methods. This could be added in future ## Docs and Testing `.mill.yaml` files are largely covered by example tests: these also form the documentation, which takes over the first two examples in `{javalib,scalalib,kotlinlib}/intro.html`, and on an additional page `{javalib,scalalib,kotlinlib}/simple.html`. Since the main logic around these `.mill.yaml` files runs as part of `MillBuildBootstrap`/`MillBuildRootModule/`CodeGen`, they cannot be easily exercised with unit tests, and so these example tests will have to do for now --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 0ee02bd commit a993068

File tree

268 files changed

+3013
-804
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

268 files changed

+3013
-804
lines changed

ci/test-dist-run.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
set -eux
44

5-
EXAMPLE=example/scalalib/basic/6-realistic
5+
EXAMPLE=example/scalalib/basic/7-realistic
66

77
rm -rf $EXAMPLE/out
88

core/api/src/mill/api/ExternalModule.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ abstract class ExternalModule(using
2222
millFile0
2323
) {
2424

25-
assert(
25+
private[mill] def allowNestedExternalModule = false
26+
if (!allowNestedExternalModule) assert(
2627
!" #".exists(millModuleEnclosing0.value.contains(_)),
2728
"External modules must be at a top-level static path, not " + millModuleEnclosing0.value
2829
)

core/api/src/mill/api/ModuleCtx.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ object ModuleCtx extends LowPriCtx {
4141
trait Wrapper {
4242
def moduleCtx: ModuleCtx
4343
private[mill] def moduleLinearized: Seq[Class[?]]
44+
private[mill] def buildOverrides: Map[String, ujson.Value] = Map()
4445
}
4546
private case class Impl(
4647
enclosing: String,

core/api/src/mill/api/PathRef.scala

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -197,26 +197,30 @@ object PathRef {
197197
storeSerializedPaths(p)
198198
p.toString()
199199
},
200-
s => {
201-
val Array(prefix, valid0, hex, pathString) = s.split(":", 4)
200+
{
201+
case s"$prefix:$valid0:$hex:$pathString" if prefix == "ref" || prefix == "qref" =>
202202

203-
val path = os.Path(pathString)
204-
val quick = prefix match {
205-
case "qref" => true
206-
case "ref" => false
207-
}
208-
val validOrig = valid0 match {
209-
case "v0" => Revalidate.Never
210-
case "v1" => Revalidate.Once
211-
case "vn" => Revalidate.Always
212-
}
213-
// Parsing to a long and casting to an int is the only way to make
214-
// round-trip handling of negative numbers work =(
215-
val sig = java.lang.Long.parseLong(hex, 16).toInt
216-
val pr = PathRef(path, quick, sig, revalidate = validOrig)
217-
validatedPaths.value.revalidateIfNeededOrThrow(pr)
218-
storeSerializedPaths(pr)
219-
pr
203+
val path = os.Path(pathString)
204+
val quick = prefix match {
205+
case "qref" => true
206+
case "ref" => false
207+
}
208+
val validOrig = valid0 match {
209+
case "v0" => Revalidate.Never
210+
case "v1" => Revalidate.Once
211+
case "vn" => Revalidate.Always
212+
}
213+
// Parsing to a long and casting to an int is the only way to make
214+
// round-trip handling of negative numbers work =(
215+
val sig = java.lang.Long.parseLong(hex, 16).toInt
216+
val pr = PathRef(path, quick, sig, revalidate = validOrig)
217+
validatedPaths.value.revalidateIfNeededOrThrow(pr)
218+
storeSerializedPaths(pr)
219+
pr
220+
case s =>
221+
mill.api.BuildCtx.withFilesystemCheckerDisabled(
222+
PathRef(os.Path(s, mill.api.BuildCtx.workspaceRoot))
223+
)
220224
}
221225
)
222226

core/api/src/mill/api/Task.scala

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,52 @@ object Task {
289289
): Simple[T] = ${ Macros.taskResultImpl[T]('t)('rw, 'ctx, '{ persistent }) }
290290
}
291291

292+
// The extra `(x: T) = null` parameter list is necessary to make type inference work
293+
// right, ensuring that `T` is fully inferred before implicit resolution starts
294+
def Literal[T](s: String)(using
295+
x: T = null.asInstanceOf[T]
296+
)(using li: LiteralImplicit[T]): Task.Simple[T] = {
297+
assert(li.ctx != null, "Unable to resolve context")
298+
assert(li.writer != null, "Unable to resolve JSON writer")
299+
assert(li.reader != null, "Unable to resolve JSON reader")
300+
new Task.Input[T](
301+
(_, _) => Result.Success(upickle.default.read[T](s)(using li.reader)),
302+
li.ctx,
303+
li.writer,
304+
None
305+
)
306+
}
307+
308+
class LiteralImplicit[T](
309+
val reader: upickle.default.Reader[T],
310+
val writer: upickle.default.Writer[T],
311+
val ctx: ModuleCtx
312+
)
313+
object LiteralImplicit {
314+
// Use a custom macro to perform the implicit lookup so we have more control over implicit
315+
// resolution failures. In this case, we want to fall back to `null` if an implicit search
316+
// fails so we can provide a good error message
317+
implicit inline def create[T]: LiteralImplicit[T] = ${ createImpl[T] }
318+
319+
private def createImpl[T: Type](using Quotes): Expr[LiteralImplicit[T]] = {
320+
import quotes.reflect.*
321+
322+
def summonOrNull[U: Type]: Expr[U] = {
323+
Implicits.search(TypeRepr.of[U]) match {
324+
case s: ImplicitSearchSuccess => s.tree.asExprOf[U] // Use the found given
325+
case _: ImplicitSearchFailure =>
326+
'{ null.asInstanceOf[U] } // Includes both NoMatchingImplicits and AmbiguousImplicits
327+
}
328+
}
329+
330+
val readerExpr = summonOrNull[upickle.default.Reader[T]]
331+
val writerExpr = summonOrNull[upickle.default.Writer[T]]
332+
val ctxExpr = summonOrNull[ModuleCtx]
333+
334+
'{ new LiteralImplicit[T]($readerExpr, $writerExpr, $ctxExpr) }
335+
}
336+
}
337+
292338
abstract class Ops[+T] { this: Task[T] =>
293339
def map[V](f: T => V): Task[V] = new Task.Mapped(this, f)
294340
def filter(f: T => Boolean): Task[T] = this

core/api/src/mill/api/internal/RootModule.scala

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,24 +26,37 @@ abstract class RootModule()(using
2626
// Provided for IDEs to think that one is available and not show errors in
2727
// build.mill/package.mill even though they can't see the codegen
2828
def millDiscover: Discover = sys.error("RootModule#millDiscover must be overridden")
29+
30+
override def buildOverrides: Map[String, ujson.Value] = baseModuleInfo.buildOverrides
2931
}
3032

3133
@internal
3234
object RootModule {
3335
class Info(
3436
val projectRoot: os.Path,
3537
val output: os.Path,
36-
val topLevelProjectRoot: os.Path
38+
val topLevelProjectRoot: os.Path,
39+
@com.lihaoyi.unroll val buildOverrides: Map[String, ujson.Value] = Map()
3740
) {
41+
3842
def this(
3943
projectRoot0: String,
4044
output0: String,
41-
topLevelProjectRoot0: String
45+
topLevelProjectRoot0: String,
46+
headerData: String
4247
) = this(
4348
os.Path(projectRoot0),
4449
os.Path(output0),
45-
os.Path(topLevelProjectRoot0)
50+
os.Path(topLevelProjectRoot0),
51+
upickle.read[Map[String, ujson.Value]](headerData)
4652
)
53+
54+
def this(
55+
projectRoot0: String,
56+
output0: String,
57+
topLevelProjectRoot0: String
58+
) = this(projectRoot0, output0, topLevelProjectRoot0, "{}")
59+
4760
implicit val millMiscInfo: Info = this
4861
}
4962

core/api/src/mill/api/internal/RootModule0.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ abstract class RootModule0(
3030
// `Discover` needs to be defined by every concrete `BaseModule` object, to gather
3131
// compile-time metadata about the tasks and commands at for use at runtime
3232
protected def millDiscover: Discover
33+
def discover: Discover = millDiscover
3334

3435
// We need to propagate the `Discover` object implicitly throughout the module tree
3536
// so it can be used for override detection

core/constants/src/mill/constants/CodeGenConstants.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,19 @@ public class CodeGenConstants {
2323
* The name of the root build file
2424
*/
2525
public static final List<String> rootBuildFileNames =
26-
List.of("build.mill", "build.mill.scala", "build.sc");
26+
List.of("build.mill.yaml", "build.mill", "build.mill.scala", "build.sc");
2727

2828
/**
2929
* The name of any sub-folder build files
3030
*/
3131
public static final List<String> nestedBuildFileNames =
32-
List.of("package.mill", "package.mill.scala", "package.sc");
32+
List.of("package.mill.yaml", "package.mill", "package.mill.scala", "package.sc");
3333

3434
/**
3535
* The extensions used by build files
3636
*/
37-
public static final List<String> buildFileExtensions = List.of("mill", "mill.scala", "sc");
37+
public static final List<String> buildFileExtensions =
38+
List.of("mill.yaml", "mill", "mill.scala", "sc");
3839

3940
/**
4041
* The user-facing name for the root of the module tree.

core/constants/src/mill/constants/Util.java

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import java.lang.reflect.InvocationTargetException;
66
import java.lang.reflect.Method;
77
import java.nio.charset.StandardCharsets;
8+
import java.nio.file.Files;
9+
import java.nio.file.Path;
810
import java.security.MessageDigest;
911
import java.security.NoSuchAlgorithmException;
1012
import java.util.ArrayList;
@@ -84,15 +86,20 @@ private static String throwBuildHeaderError(
8486
+ ": " + line + "\n" + msg);
8587
}
8688

87-
public static String readBuildHeader(java.nio.file.Path buildFile, String errorFileName) {
89+
public static String readBuildHeader(Path buildFile, String errorFileName) {
90+
return readBuildHeader(buildFile, errorFileName, false);
91+
}
92+
93+
public static String readBuildHeader(
94+
Path buildFile, String errorFileName, boolean allowNonBuild) {
8895
try {
89-
java.util.List<String> lines = java.nio.file.Files.readAllLines(buildFile);
96+
java.util.List<String> lines = Files.readAllLines(buildFile);
9097
boolean readingBuildHeader = true;
9198
java.util.List<String> output = new ArrayList<>();
9299
for (int i = 0; i < lines.size(); i++) {
93100
String line = lines.get(i);
94101
if (!line.startsWith("//|")) readingBuildHeader = false;
95-
else if (!buildFile.getFileName().toString().startsWith("build.")) {
102+
else if (!allowNonBuild && !buildFile.getFileName().toString().startsWith("build.")) {
96103
throwBuildHeaderError(
97104
errorFileName,
98105
i,

core/eval/src/mill/eval/EvaluatorImpl.scala

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,13 @@ import mill.resolve.Resolve
2424
final class EvaluatorImpl private[mill] (
2525
private[mill] val allowPositionalCommandArgs: Boolean,
2626
private[mill] val selectiveExecution: Boolean = false,
27-
private val execution: Execution
27+
private val execution: Execution,
28+
scriptModuleResolver: (
29+
String,
30+
String => Option[mill.Module],
31+
Boolean,
32+
Option[String]
33+
) => Seq[Result[mill.api.ExternalModule]]
2834
) extends Evaluator {
2935

3036
private[mill] def workspace = execution.workspace
@@ -40,9 +46,18 @@ final class EvaluatorImpl private[mill] (
4046
def withBaseLogger(newBaseLogger: Logger): Evaluator = new EvaluatorImpl(
4147
allowPositionalCommandArgs,
4248
selectiveExecution,
43-
execution.withBaseLogger(newBaseLogger)
49+
execution.withBaseLogger(newBaseLogger),
50+
scriptModuleResolver
4451
)
4552

53+
private[mill] def resolveSingleModule(s: String): Option[mill.Module] = {
54+
resolveModulesOrTasks(Seq(s), SelectMode.Multi)
55+
.toOption
56+
.toSeq
57+
.flatten
58+
.collectFirst { case Left(m) => m }
59+
}
60+
4661
/**
4762
* Takes query selector tokens and resolves them to a list of [[Segments]]
4863
* representing concrete tasks or modules that match that selector
@@ -59,7 +74,8 @@ final class EvaluatorImpl private[mill] (
5974
scriptArgs,
6075
selectMode,
6176
allowPositionalCommandArgs,
62-
resolveToModuleTasks
77+
resolveToModuleTasks,
78+
scriptModuleResolver = scriptModuleResolver(_, resolveSingleModule, _, _)
6379
)
6480
}
6581
}
@@ -75,7 +91,8 @@ final class EvaluatorImpl private[mill] (
7591
scriptArgs,
7692
selectMode,
7793
allowPositionalCommandArgs,
78-
resolveToModuleTasks
94+
resolveToModuleTasks,
95+
scriptModuleResolver = scriptModuleResolver(_, resolveSingleModule, _, _)
7996
)
8097
}
8198
}
@@ -97,7 +114,8 @@ final class EvaluatorImpl private[mill] (
97114
scriptArgs,
98115
selectMode,
99116
allowPositionalCommandArgs,
100-
resolveToModuleTasks
117+
resolveToModuleTasks,
118+
scriptModuleResolver = scriptModuleResolver(_, resolveSingleModule, _, _)
101119
)
102120
}
103121
}
@@ -115,7 +133,8 @@ final class EvaluatorImpl private[mill] (
115133
scriptArgs,
116134
selectMode,
117135
allowPositionalCommandArgs,
118-
resolveToModuleTasks
136+
resolveToModuleTasks,
137+
scriptModuleResolver = scriptModuleResolver(_, resolveSingleModule, _, _)
119138
)
120139
}
121140
}
@@ -276,7 +295,8 @@ final class EvaluatorImpl private[mill] (
276295
rootModule,
277296
scriptArgs,
278297
selectMode,
279-
allowPositionalCommandArgs
298+
allowPositionalCommandArgs,
299+
scriptModuleResolver = scriptModuleResolver(_, resolveSingleModule, _, _)
280300
)
281301
}
282302
}

0 commit comments

Comments
 (0)