Skip to content

Conversation

lihaoyi
Copy link
Member

@lihaoyi lihaoyi commented Sep 11, 2025

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 mill.yaml

mill.yaml

extends: "mill.javalib.JavaModule.Simple"
mvnDeps:
- "net.sourceforge.argparse4j:argparse4j:0.9.0"
- "org.thymeleaf:thymeleaf:3.1.1.RELEASE"

test/mill.yaml

extends: "mill.javalib.JavaModule.Junit4"
moduleDeps: ["."]
mvnDeps:
- "com.google.guava:guava:33.3.0-jre"
> ./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

//| 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)
}
> ./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

  • Each mill.yaml simple module or *.{java,scala,kt} script file on disk is instantiated into an ExternalModule in Mill, and can be configured via YAML data and depend on each other and on traditional "Programmatic Modules" (and vice versa). Each one instantiates a mill.simple.SimpleModule specified via extends:, and users can define their own concrete subclass of SimpleModule to use in their simple modules if the defaults do not do everything they want.

    • As they are just mill.Modules, you can run multiple simple modules or script files together with +, or run them together with programmatic modules, or use them with show or inspect. They also can depend on each other arbitrarily, since in the end they are all mill.Modules in the Mill daemon classloader
  • Parsing the query selectors is done by first attempting a parse-and-resolution for normal programmatic modules and tasks, and only if that fails do we attempt a parse-and-resolution for simple yaml-backed modules. This preserves backwards compatibility by ensuring queries that resolved to programmatic modules in the past continue to do so unchanged, while allowing a concise syntax for referencing simple yaml-backed modules

  • We re-use most of the infrastructure from Mill's existing YAML build headers, though refactored so the YAML metadata can apply to arbitrary modules rather than only the root meta-build module.

  • As we need to instantiate these modules reflectively, we cannot use traits or abstract classes, and so the modules that mill.yaml files or single-file scripts use in extends need to be concrete classes. These are configured to take a SimpleModule.Config case class injected by Mill containing non-task metadata like the script file path and moduleDeps, though we can extend it to contain additional fields in future if necessary

    • Since we can no longer have abstract fields, mandatory fields within this concrete class modules need to be stubbed out with = Task { ??? }

TODO

  • Simple equivalents of MavenModule/MavenKotlinModule/SbtModule
  • More thorough examples of configuring simple modules
  • Error reporting on invalid keys
  • Docs overhaul
  • Depending on simple modules from programmatic modules
  • IDE support in mill.yaml files and build headers

Supersedes #5826

@lihaoyi lihaoyi force-pushed the single-file-projects branch from ecae588 to 32888ba Compare September 11, 2025 05:56
@lihaoyi lihaoyi force-pushed the single-file-projects branch from 7e884cf to 7bf6fae Compare September 12, 2025 11:11
@lihaoyi lihaoyi force-pushed the single-file-projects branch from 7bf6fae to b3ab4f0 Compare September 12, 2025 12:17
@lihaoyi lihaoyi force-pushed the single-file-projects branch from f7cf97c to fb36045 Compare September 12, 2025 14:43
@lihaoyi lihaoyi changed the title Single file projects Single file modules Sep 13, 2025
@lihaoyi lihaoyi force-pushed the single-file-projects branch from 912d4d5 to 167d73c Compare September 18, 2025 04:09
@lefou
Copy link
Member

lefou commented Sep 18, 2025

@lihaoyi

It does mean we would not be able to avoid using the Scala compiler to compile things.
...
But any error messages in the codegen would point at the generated code, which is less nice

I think the generated code should only use well tested code blocks, so the chance of a compile error is rather low, since if we managed to fully generate the code, we already know how to map each found YAML section to such a tested code block.

Recompilation is probably only needed, if the extends section changes. The generated task impls should just depend (task-wise) on the YAML and forward the respective data dynamically.

(We might even have pre-compiled backing impls for most polular traits like JavaModule and ScalaModule.)

@lefou
Copy link
Member

lefou commented Sep 18, 2025

We should move the discussion of the topic into a dedicated issue.

@lihaoyi
Copy link
Member Author

lihaoyi commented Sep 19, 2025

CC @alexarchambault @arturaz if you have a moment to review this PR and feature proposal it would be great to get your thoughts as well

@lefou
Copy link
Member

lefou commented Sep 19, 2025

Due to time constraints, I couldn't make a thorough review. OTOH, I fear an accidental/premature merge of this feature before we had a proper discussion, which I think is necessary. I want to remind us that once, we merge, we need to stick to that feature, since it is rather tightly coupled (located inside the existing modules, special CLI handling, ...).

In addition to my previous comments and ideas, which are not directly related to this PR but to the general concept, here are some quick notes regarding this PR.

File names

The mill.yaml naming scheme feel incosistent with the current build.mill/package.mill scheme.

  • root project: mill.yaml vs build.mill
  • sub-project: mill.yaml vs. package.mill.

build.yaml/package.yaml would feel more consistent, although it does not contain the tool in the same.

Should we use build.mill.yaml/package.mill.yaml instead?

Option handling

Also, I think we should not skip the need to pass a --file/-f option for now. It feels premature, as it forces use to stick to that handling for a reasonable future, although we haven't gathered any experience and user feedback for that feature at all.

Specifying an alternative build file with --file/-f` is common practice for build tools and is probably not much a hassle. It would also allow us to mark it as "preview feature", so it is more explicit to users that it might change in future version, depending on the feedback and experience we got.

Simple modules

We should not make the simple modules like mill.javalib.JavaModule.Simple sub-classes or object-subs of the existing modules. For practical reasons. The existing module sources files are already large and overloaded. We should avoid a pattern that forces us to have even more stuff in a single source file. A separate class and file will IMHO ease maintenance and potential future changes. E.g. we might later come up with the ability to directly use module traits or decide to package it in a different jar.

To establish some naming pattern, we could place all the simple support files/classes in a simple sub-package, so mill.javalib.JavaModule.Simple becomes mill.javalib.simple.JavaModule.

@lihaoyi lihaoyi force-pushed the single-file-projects branch from 01ff4a2 to 1c2f830 Compare October 3, 2025 21:10
@lihaoyi
Copy link
Member Author

lihaoyi commented Oct 4, 2025

Closing in favor of #5951

@lihaoyi lihaoyi closed this Oct 4, 2025
lihaoyi added a commit that referenced this pull request Oct 4, 2025
…ration (#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants