diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml
index d42d89e5a843..0eb23fbf7164 100644
--- a/.github/workflows/run-tests.yml
+++ b/.github/workflows/run-tests.yml
@@ -154,6 +154,10 @@ jobs:
millargs: "'example.androidlib.__.local.daemon'"
setup-android: true
+ - java-version: 17
+ millargs: "'example.groovylib.__.local.daemon'"
+ setup-android: true
+
- java-version: 17
millargs: "'example.thirdparty[androidtodo].packaged.daemon'"
setup-android: true
diff --git a/core/api/daemon/src/mill/api/daemon/internal/GroovyModuleApi.scala b/core/api/daemon/src/mill/api/daemon/internal/GroovyModuleApi.scala
new file mode 100644
index 000000000000..c64a187cfb27
--- /dev/null
+++ b/core/api/daemon/src/mill/api/daemon/internal/GroovyModuleApi.scala
@@ -0,0 +1,3 @@
+package mill.api.daemon.internal
+
+trait GroovyModuleApi extends JavaModuleApi
diff --git a/core/api/daemon/src/mill/api/daemon/internal/bsp/BspModuleApi.scala b/core/api/daemon/src/mill/api/daemon/internal/bsp/BspModuleApi.scala
index df66de24649c..43ea186bd958 100644
--- a/core/api/daemon/src/mill/api/daemon/internal/bsp/BspModuleApi.scala
+++ b/core/api/daemon/src/mill/api/daemon/internal/bsp/BspModuleApi.scala
@@ -15,6 +15,7 @@ object BspModuleApi {
val Java = "java"
val Scala = "scala"
val Kotlin = "kotlin"
+ val Groovy = "groovy"
}
/** Used to define the [[BspBuildTarget.tags]] field. */
diff --git a/example/groovylib/basic/1-simple/build.mill b/example/groovylib/basic/1-simple/build.mill
new file mode 100644
index 000000000000..246277785c36
--- /dev/null
+++ b/example/groovylib/basic/1-simple/build.mill
@@ -0,0 +1,124 @@
+//// SNIPPET:BUILD
+
+package build
+import mill.*, groovylib.*
+
+object foo extends GroovyModule {
+ def groovyVersion = "5.0.1"
+
+ def mvnDeps = Seq(
+ mvn"org.apache.groovy:groovy-cli-commons", // BOM already loaded by module
+ mvn"org.apache.groovy:groovy-xml" // BOM already loaded by module
+ )
+
+ def mainClass = Some("foo.Foo")
+
+ object test extends GroovyTests with TestModule.Junit5 {
+ def jupiterVersion = "5.13.4"
+
+ def mvnDeps = Seq(
+ mvn"org.apache.groovy:groovy-test" // BOM already loaded by module
+ )
+ }
+}
+
+// This is a basic Mill build for a single `GroovyModule`, with one
+// third-party dependency and a test suite using the JUnit framework.
+//// SNIPPET:TREE
+// ----
+// build.mill
+// foo/
+// src/
+// foo/Foo.groovy
+// resources/
+// ...
+// test/
+// src/
+// foo/FooTest.groovy
+// out/foo/
+// compile.json
+// compile.dest/
+// ...
+// test/
+// compile.json
+// compile.dest/
+// ...
+// ----
+//
+// NOTE: The default Mill source folder layout `foo/src/` differs from that of Maven/Gradle's
+// `foo/src/main/groovy`. If you wish to use the Maven source folder layout, e.g. for migrating
+// an existing codebase, you should use
+// xref:#_maven_compatible_modules[Maven-Compatible Modules]
+//
+//// SNIPPET:DEPENDENCIES
+//
+// This example project uses two third-party dependencies
+// - Groovy-Cli-Commons for CLI argument parsing
+// - Groovy-Xml for HTML templating and escaping
+// and uses them to wrap a given input string in HTML templates with proper escaping.
+//
+// Typical usage of a `GroovyModule` is shown below
+
+/** Usage
+
+> ./mill resolve foo._ # List what tasks are available to run
+foo.assembly
+...
+foo.compile
+...
+foo.run
+...
+*/
+/** Usage
+> ./mill inspect foo.compile # Show documentation and inputs of a task
+foo.compile(GroovyModule...)
+ Compiles all the sources to JVM class files.
+ Compiles the current module to generate compiled classfiles/bytecode.
+Inputs:
+ foo.allJavaSourceFiles
+ foo.allGroovySourceFiles
+ foo.compileClasspath
+ foo.upstreamCompileOutput
+ foo.javacOptions
+ foo.zincReportCachedProblems
+...
+*/
+/** Usage
+> ./mill foo.compile # compile sources into classfiles
+...
+Compiling 1 Groovy sources to...
+*/
+/** Usage
+> ./mill foo.run # run the main method, if any
+error: Error: missing option --text
+...
+*/
+/** Usage
+> ./mill foo.run --text hello
+
hello
+*/
+/** Usage
+> ./mill foo.test
+...
+Test foo.FooTest testSimple finished, ...
+Test foo.FooTest testEscaping finished, ...
+Test foo.FooTest finished, ...
+Test run finished: 0 failed, 0 ignored, 2 total, ...
+*/
+/** Usage
+> ./mill foo.assembly # bundle classfiles and libraries into a jar for deployment
+
+> ./mill show foo.assembly # show the output of the assembly task
+".../out/foo/assembly.dest/out.jar"
+
+> java -jar ./out/foo/assembly.dest/out.jar --text hello
+hello
+
+> ./out/foo/assembly.dest/out.jar --text hello # mac/linux
+hello
+
+> cp ./out/foo/assembly.dest/out.jar out.bat # windows
+
+> ./out.bat --text hello # windows
+hello
+*/
diff --git a/example/groovylib/basic/1-simple/foo/src/foo/Foo.groovy b/example/groovylib/basic/1-simple/foo/src/foo/Foo.groovy
new file mode 100644
index 000000000000..10e0cc77b475
--- /dev/null
+++ b/example/groovylib/basic/1-simple/foo/src/foo/Foo.groovy
@@ -0,0 +1,32 @@
+package foo
+
+import groovy.xml.MarkupBuilder
+import groovy.cli.commons.CliBuilder
+
+class Foo {
+ static String generateHtml(String text) {
+ def writer = new StringWriter()
+ new MarkupBuilder(writer).h1 {
+ mkp.yield text
+ }
+ writer.toString()
+ }
+
+ static void main(String[] args) {
+ def cli = new CliBuilder(usage:'help')
+ cli.t(longOpt:'text', args: 1, 'Passes text to the HTML generation')
+ def options = cli.parse(args)
+
+ if (!options) {
+ return
+ }
+
+ if (options.h) {
+ cli.usage()
+ return
+ }
+
+ String textToProcess = options.t ?: "hello from main"
+ println generateHtml(textToProcess)
+ }
+}
diff --git a/example/groovylib/basic/1-simple/foo/test/src/foo/FooTest.groovy b/example/groovylib/basic/1-simple/foo/test/src/foo/FooTest.groovy
new file mode 100644
index 000000000000..6e8f048873c7
--- /dev/null
+++ b/example/groovylib/basic/1-simple/foo/test/src/foo/FooTest.groovy
@@ -0,0 +1,15 @@
+package foo
+
+import org.junit.jupiter.api.Test
+
+class FooTest {
+ @Test
+ void "generate html created properly"() {
+ assert Foo.generateHtml("hello") == "hello
"
+ }
+
+ @Test
+ void "generated html is properly escaped"() {
+ assert Foo.generateHtml("") == "<hello>
"
+ }
+}
diff --git a/example/groovylib/basic/2-compat-modules/build.mill b/example/groovylib/basic/2-compat-modules/build.mill
new file mode 100644
index 000000000000..144115e09071
--- /dev/null
+++ b/example/groovylib/basic/2-compat-modules/build.mill
@@ -0,0 +1,57 @@
+//// SNIPPET:ALL
+// Mill's default folder layout of `foo/src/` and `foo/test/src` differs from that
+// of Maven or Gradle's `foo/src/main/groovy/` and `foo/src/test/groovy/`. If you are
+// migrating an existing codebase, you can use Mill's `GroovyMavenModule` and
+// `GroovyMavenTests` as shown below to preserve filesystem compatibility with an existing
+// Maven or Gradle build:
+
+package build
+import mill.*, groovylib.*
+
+object foo extends GroovyMavenModule {
+
+ def groovyVersion = "5.0.1"
+
+ object test extends GroovyMavenTests with TestModule.Junit5 {}
+ object integration extends GroovyMavenTests with TestModule.Junit5 {}
+}
+
+// `GroovyMavenModule` is a variant of `GroovyModule`
+// that uses the more verbose folder layout of Maven, `sbt`, and other tools:
+//
+// - `foo/src/main/java`
+// - `foo/src/main/groovy`
+// - `foo/src/test/java`
+// - `foo/src/test/groovy`
+// - `foo/src/integration/java`
+// - `foo/src/integration/groovy`
+//
+// Rather than Mill's
+//
+// - `foo/src`
+// - `foo/test/src`
+//
+// This is especially useful if you are migrating from Maven to Mill (or vice
+// versa), during which a particular module may be built using both Maven and
+// Mill at the same time
+
+/** Usage
+
+> ./mill foo.compile
+Compiling 1 Groovy source...
+
+> ./mill foo.test.compile
+Compiling 1 Groovy source...
+
+> ./mill foo.test.testForked
+...foo.FooTests hello ...
+
+> ./mill foo.test
+...foo.FooTests hello ...
+
+> ./mill foo.integration
+...foo.FooIntegrationTests hello ...
+
+*/
+
+// For more details on migrating from other build tools, see xref:migrating/migrating.adoc[]
diff --git a/example/groovylib/basic/2-compat-modules/foo/src/integration/groovy/foo/FooIntegrationTests.groovy b/example/groovylib/basic/2-compat-modules/foo/src/integration/groovy/foo/FooIntegrationTests.groovy
new file mode 100644
index 000000000000..a5c50328c1ff
--- /dev/null
+++ b/example/groovylib/basic/2-compat-modules/foo/src/integration/groovy/foo/FooIntegrationTests.groovy
@@ -0,0 +1,12 @@
+package foo
+
+import org.junit.jupiter.api.Test
+
+class FooIntegrationTests {
+ @Test
+ void "hello should print correct greeting"() {
+ // Groovy creates an implicit class for the script named after the file
+ def foo = new Foo()
+ assert foo.hello() == "Hello World, Earth"
+ }
+}
diff --git a/example/groovylib/basic/2-compat-modules/foo/src/main/groovy/foo/Foo.groovy b/example/groovylib/basic/2-compat-modules/foo/src/main/groovy/foo/Foo.groovy
new file mode 100644
index 000000000000..0054d20ec1b1
--- /dev/null
+++ b/example/groovylib/basic/2-compat-modules/foo/src/main/groovy/foo/Foo.groovy
@@ -0,0 +1,9 @@
+package foo
+
+def run(){
+ println(hello())
+}
+
+def hello() {
+ "Hello World, ${Foo2.VALUE}"
+}
diff --git a/example/groovylib/basic/2-compat-modules/foo/src/main/java/foo/Foo2.java b/example/groovylib/basic/2-compat-modules/foo/src/main/java/foo/Foo2.java
new file mode 100644
index 000000000000..47684927c22e
--- /dev/null
+++ b/example/groovylib/basic/2-compat-modules/foo/src/main/java/foo/Foo2.java
@@ -0,0 +1,5 @@
+package foo;
+
+public class Foo2 {
+ public static final String VALUE = "Earth";
+}
diff --git a/example/groovylib/basic/2-compat-modules/foo/src/test/groovy/foo/FooTests.groovy b/example/groovylib/basic/2-compat-modules/foo/src/test/groovy/foo/FooTests.groovy
new file mode 100644
index 000000000000..80ba076e8959
--- /dev/null
+++ b/example/groovylib/basic/2-compat-modules/foo/src/test/groovy/foo/FooTests.groovy
@@ -0,0 +1,13 @@
+package foo
+
+import org.junit.jupiter.api.Test
+
+class FooTest {
+
+ @Test
+ void "hello should print correct greeting"() {
+ // Groovy creates an implicit class for the script named after the file
+ def foo = new Foo()
+ assert foo.hello() == "Hello World, Earth"
+ }
+}
\ No newline at end of file
diff --git a/example/groovylib/testing/1-test-suite/bar/src/bar/Bar.groovy b/example/groovylib/testing/1-test-suite/bar/src/bar/Bar.groovy
new file mode 100644
index 000000000000..9ea74e14b77b
--- /dev/null
+++ b/example/groovylib/testing/1-test-suite/bar/src/bar/Bar.groovy
@@ -0,0 +1,9 @@
+package bar
+
+def hello() {
+ "Hello World"
+}
+
+def run(){
+ println new Bar().hello()
+}
diff --git a/example/groovylib/testing/1-test-suite/bar/test/src/bar/BarTests.groovy b/example/groovylib/testing/1-test-suite/bar/test/src/bar/BarTests.groovy
new file mode 100644
index 000000000000..3f9c8202d182
--- /dev/null
+++ b/example/groovylib/testing/1-test-suite/bar/test/src/bar/BarTests.groovy
@@ -0,0 +1,29 @@
+package bar
+
+import org.junit.jupiter.api.Test
+import groovy.mock.interceptor.MockFor
+
+class BarTests{
+ @Test
+ void "hello"() {
+ def result = new Bar().hello()
+ assert result == "Hello World"
+ }
+
+ @Test
+ void "world"() {
+ def result = new Bar().hello()
+ assert result.endsWith("World")
+ }
+
+ @Test
+ void "using groovy mocks"() {
+ def mockBar = new MockFor(Bar)
+ mockBar.demand.hello { "Hello GroovyMock World" }
+
+ mockBar.use{
+ def result = new Bar().hello()
+ assert result == "Hello GroovyMock World"
+ }
+ }
+}
diff --git a/example/groovylib/testing/1-test-suite/build.mill b/example/groovylib/testing/1-test-suite/build.mill
new file mode 100644
index 000000000000..652533e72b59
--- /dev/null
+++ b/example/groovylib/testing/1-test-suite/build.mill
@@ -0,0 +1,31 @@
+//// SNIPPET:BUILD1
+package build
+import mill.*, groovylib.*
+
+object foo extends GroovyModule {
+
+ def groovyVersion = "5.0.1"
+
+ object test extends GroovyTests with TestModule.Junit5 {
+ def jupiterVersion = "5.13.4"
+
+ def mvnDeps = Seq(
+ mvn"org.apache.groovy:groovy-test" // BOM already loaded by module
+ )
+ }
+}
+
+object bar extends GroovyModule {
+
+ def mainClass = Some("bar.Bar")
+
+ def groovyVersion = "5.0.1"
+
+ object test extends GroovyTests, TestModule.Junit5 {
+ def jupiterVersion = "5.13.4"
+
+ def mvnDeps = Seq(
+ mvn"org.apache.groovy:groovy-test" // BOM already loaded by module
+ )
+ }
+}
diff --git a/example/groovylib/testing/1-test-suite/foo/src/foo/Foo.groovy b/example/groovylib/testing/1-test-suite/foo/src/foo/Foo.groovy
new file mode 100644
index 000000000000..3145fff350e6
--- /dev/null
+++ b/example/groovylib/testing/1-test-suite/foo/src/foo/Foo.groovy
@@ -0,0 +1,11 @@
+package foo
+
+class Foo {
+ def hello() {
+ "Hello World"
+ }
+
+ static void main(String[] args){
+ println Foo().hello()
+ }
+}
diff --git a/example/groovylib/testing/1-test-suite/foo/test/src/foo/FooTests.groovy b/example/groovylib/testing/1-test-suite/foo/test/src/foo/FooTests.groovy
new file mode 100644
index 000000000000..5390008ede06
--- /dev/null
+++ b/example/groovylib/testing/1-test-suite/foo/test/src/foo/FooTests.groovy
@@ -0,0 +1,29 @@
+package foo
+
+import org.junit.jupiter.api.Test
+import groovy.mock.interceptor.MockFor
+
+class FooMoreTests{
+ @Test
+ void "hello"() {
+ def result = new Foo().hello()
+ assert result == "Hello World"
+ }
+
+ @Test
+ void "world"() {
+ def result = new Foo().hello()
+ assert result.endsWith("World")
+ }
+
+ @Test
+ void "using groovy mocks"() {
+ def mockFoo = new MockFor(Foo)
+ mockFoo.demand.hello { "Hello GroovyMock World" }
+
+ mockFoo.use{
+ def result = new Foo().hello()
+ assert result == "Hello GroovyMock World"
+ }
+ }
+}
diff --git a/example/groovylib/testing/2-integration-suite/build.mill b/example/groovylib/testing/2-integration-suite/build.mill
new file mode 100644
index 000000000000..04c4f0f0a3da
--- /dev/null
+++ b/example/groovylib/testing/2-integration-suite/build.mill
@@ -0,0 +1,20 @@
+//// SNIPPET:BUILD2
+package build
+import mill.*, groovylib.*
+object qux extends GroovyModule {
+
+ def groovyVersion = "5.0.1"
+
+ object test extends GroovyTests with TestModule.Junit5 {
+ def mvnDeps = super.mvnDeps() ++ Seq(
+ mvn"org.apache.groovy:groovy-test"
+ )
+ }
+ object integration extends GroovyTests with TestModule.Junit5 {
+ def mvnDeps = super.mvnDeps() ++ Seq(
+ mvn"org.apache.groovy:groovy-test"
+ )
+ }
+}
+
+// The integration suite is just another regular test module within the parent GroovyModule
diff --git a/example/groovylib/testing/2-integration-suite/qux/integration/src/qux/QuxIntegrationTests.groovy b/example/groovylib/testing/2-integration-suite/qux/integration/src/qux/QuxIntegrationTests.groovy
new file mode 100644
index 000000000000..c04f366bd689
--- /dev/null
+++ b/example/groovylib/testing/2-integration-suite/qux/integration/src/qux/QuxIntegrationTests.groovy
@@ -0,0 +1,18 @@
+package qux
+
+import org.junit.jupiter.api.Test
+
+class QuxIntegrationTests {
+
+ @Test
+ void "hello"() {
+ def result = new Qux().hello()
+ assert result == "Hello World"
+ }
+
+ @Test
+ void "world"() {
+ def result = new Qux().hello()
+ assert result.endsWith("World")
+ }
+}
diff --git a/example/groovylib/testing/2-integration-suite/qux/src/qux/Qux.groovy b/example/groovylib/testing/2-integration-suite/qux/src/qux/Qux.groovy
new file mode 100644
index 000000000000..c04e966f9105
--- /dev/null
+++ b/example/groovylib/testing/2-integration-suite/qux/src/qux/Qux.groovy
@@ -0,0 +1,11 @@
+package qux
+
+class Qux {
+ static void main(String[] args){
+ println(hello())
+ }
+
+ String hello(){
+ "Hello World"
+ }
+}
diff --git a/example/groovylib/testing/2-integration-suite/qux/test/src/qux/QuxTests.groovy b/example/groovylib/testing/2-integration-suite/qux/test/src/qux/QuxTests.groovy
new file mode 100644
index 000000000000..b97ae1a45cde
--- /dev/null
+++ b/example/groovylib/testing/2-integration-suite/qux/test/src/qux/QuxTests.groovy
@@ -0,0 +1,18 @@
+package qux
+
+import org.junit.jupiter.api.Test
+
+class QuxTests {
+
+ @Test
+ void "hello"() {
+ def result = new Qux().hello()
+ assert result == "Hello World"
+ }
+
+ @Test
+ void "world"() {
+ def result = new Qux().hello()
+ assert result.endsWith("World")
+ }
+}
\ No newline at end of file
diff --git a/example/groovylib/testing/3-spock/build.mill b/example/groovylib/testing/3-spock/build.mill
new file mode 100644
index 000000000000..331a776db914
--- /dev/null
+++ b/example/groovylib/testing/3-spock/build.mill
@@ -0,0 +1,13 @@
+//// SNIPPET:BUILD4
+package build
+import mill.*, groovylib.*
+
+object `package` extends GroovyModule {
+
+ def groovyVersion = "4.0.28"
+
+ object test extends GroovyTests with TestModule.Spock {
+ def spockVersion = "2.3-groovy-4.0"
+ def jupiterVersion = "5.13.4"
+ }
+}
diff --git a/example/groovylib/testing/3-spock/src/foo/Calculator.groovy b/example/groovylib/testing/3-spock/src/foo/Calculator.groovy
new file mode 100644
index 000000000000..015c700376b9
--- /dev/null
+++ b/example/groovylib/testing/3-spock/src/foo/Calculator.groovy
@@ -0,0 +1,8 @@
+package foo
+
+class Calculator {
+
+ int add(int a, int b) {
+ return a + b
+ }
+}
diff --git a/example/groovylib/testing/3-spock/test/foo/CalculatorSpec.groovy b/example/groovylib/testing/3-spock/test/foo/CalculatorSpec.groovy
new file mode 100644
index 000000000000..35f4535ab7f6
--- /dev/null
+++ b/example/groovylib/testing/3-spock/test/foo/CalculatorSpec.groovy
@@ -0,0 +1,17 @@
+package foo
+
+def "Calculator add: #a + #b = #expected"(){
+ given:
+ def sut = new Calculator()
+
+ when:
+ def result = sut.add(a, b)
+
+ then:
+ result == expected
+
+ where:
+ a | b | expected
+ 2 | 2 | 4
+ -2 | -2 | -4
+}
\ No newline at end of file
diff --git a/example/groovylib/testing/4-spock-for-java/build.mill b/example/groovylib/testing/4-spock-for-java/build.mill
new file mode 100644
index 000000000000..d6be164c3b44
--- /dev/null
+++ b/example/groovylib/testing/4-spock-for-java/build.mill
@@ -0,0 +1,22 @@
+//// SNIPPET:BUILD5
+package build
+import mill.*, groovylib.*
+
+object `package` extends JavaMavenModuleWithGroovyTests {
+
+ object test extends GroovyMavenTests with TestModule.Spock {
+ def spockVersion = "2.3-groovy-4.0"
+ def groovyVersion = "4.0.28"
+ def jupiterVersion = "5.13.4"
+ }
+}
+
+// `JavaMavenModuleWithGroovyTests` is a variant of `JavaModule` which also
+// includes `MavenModule` and has a special `test` trait for Groovy tests:
+//
+// - `foo/src/main/java`
+// - `foo/src/test/groovy`
+//
+// This is usefull for Java projects which use Groovy only for testing.
+// For non Maven-layout projects, this convenience trait is not necessary
+// because you can just use `GroovyModule` for your test object instead.
diff --git a/example/groovylib/testing/4-spock-for-java/src/main/java/foo/Calculator.java b/example/groovylib/testing/4-spock-for-java/src/main/java/foo/Calculator.java
new file mode 100644
index 000000000000..bee08ac9ad6e
--- /dev/null
+++ b/example/groovylib/testing/4-spock-for-java/src/main/java/foo/Calculator.java
@@ -0,0 +1,8 @@
+package foo;
+
+public class Calculator {
+
+ public static int add(int a, int b) {
+ return a + b;
+ }
+}
diff --git a/example/groovylib/testing/4-spock-for-java/src/test/groovy/foo/CalculatorSpec.groovy b/example/groovylib/testing/4-spock-for-java/src/test/groovy/foo/CalculatorSpec.groovy
new file mode 100644
index 000000000000..465b2bb4cd5e
--- /dev/null
+++ b/example/groovylib/testing/4-spock-for-java/src/test/groovy/foo/CalculatorSpec.groovy
@@ -0,0 +1,12 @@
+package foo
+
+def "Calculator add: #a + #b = #result"(){
+ expect:
+ Calculator.add(a, b) == result
+
+ where:
+ a | b
+ 2 | 2
+ -2 | -2
+ result = a + b
+}
\ No newline at end of file
diff --git a/example/package.mill b/example/package.mill
index df36b154cdcb..197641a1cbcf 100644
--- a/example/package.mill
+++ b/example/package.mill
@@ -80,6 +80,11 @@ object `package` extends Module {
object testing extends Cross[ExampleCrossModule](build.listCross)
}
+ object groovylib extends Module {
+ object basic extends Cross[ExampleCrossModule](build.listCross)
+ object testing extends Cross[ExampleCrossModule](build.listCross)
+ }
+
object cli extends Module {
object builtins extends Cross[ExampleCrossModule](build.listCross)
object header extends Cross[ExampleCrossModule](build.listCross)
diff --git a/libs/groovylib/api/src/mill/groovylib/worker/api/GroovyCompilerConfiguration.scala b/libs/groovylib/api/src/mill/groovylib/worker/api/GroovyCompilerConfiguration.scala
new file mode 100644
index 000000000000..0206c3d2d4fe
--- /dev/null
+++ b/libs/groovylib/api/src/mill/groovylib/worker/api/GroovyCompilerConfiguration.scala
@@ -0,0 +1,8 @@
+package mill.groovylib.worker.api
+
+@mill.api.experimental
+case class GroovyCompilerConfiguration(
+ enablePreview: Boolean = false,
+ disabledGlobalAstTransformations: Set[String] = Set.empty,
+ targetBytecode: Option[String] = None
+)
diff --git a/libs/groovylib/api/src/mill/groovylib/worker/api/GroovyWorker.scala b/libs/groovylib/api/src/mill/groovylib/worker/api/GroovyWorker.scala
new file mode 100644
index 000000000000..ef1e8916e510
--- /dev/null
+++ b/libs/groovylib/api/src/mill/groovylib/worker/api/GroovyWorker.scala
@@ -0,0 +1,43 @@
+package mill.groovylib.worker.api
+
+import mill.api.{Result, TaskCtx}
+import mill.javalib.api.CompilationResult
+
+/**
+ * Runs the actual compilation.
+ *
+ * Supports 3-stage compilation for Java <-> Groovy
+ * 1. compile Java stubs
+ * 2. compile Java sources (done externally)
+ * 3. compile Groovy sources
+ */
+@mill.api.experimental
+trait GroovyWorker {
+
+ /**
+ * In a mixed setup this will compile the Groovy sources to Java stubs.
+ */
+ def compileGroovyStubs(
+ sourceFiles: Seq[os.Path],
+ classpath: Seq[os.Path],
+ outputDir: os.Path,
+ config: GroovyCompilerConfiguration
+ )(implicit
+ ctx: TaskCtx
+ )
+ : Result[Unit]
+
+ /**
+ * Compiles the Groovy sources. In a mixed setup this method assumes that the Java stubs
+ * are already present in the outputDir.
+ */
+ def compile(
+ sourceFiles: Seq[os.Path],
+ classpath: Seq[os.Path],
+ outputDir: os.Path,
+ config: GroovyCompilerConfiguration
+ )(implicit
+ ctx: TaskCtx
+ )
+ : Result[CompilationResult]
+}
diff --git a/libs/groovylib/package.mill b/libs/groovylib/package.mill
new file mode 100644
index 000000000000..281ab876a443
--- /dev/null
+++ b/libs/groovylib/package.mill
@@ -0,0 +1,44 @@
+package build.libs.groovylib
+
+import mill.*
+import mill.contrib.buildinfo.BuildInfo
+import mill.scalalib.*
+import millbuild.*
+
+object `package` extends MillPublishScalaModule with BuildInfo {
+
+ def moduleDeps = Seq(build.libs.util, build.libs.javalib, build.libs.javalib.testrunner, api)
+ def localTestExtraModules: Seq[MillJavaModule] =
+ super.localTestExtraModules ++ Seq(worker)
+
+ override def testMvnDeps: T[Seq[Dep]] = super.testMvnDeps() ++ Seq(
+ Deps.asmTree
+ )
+
+ def buildInfoPackageName = "mill.groovylib"
+ def buildInfoObjectName = "Versions"
+ def buildInfoMembers = Seq(
+ BuildInfo.Value("groovyVersion", Deps.groovyVersion, "Version of Groovy")
+ )
+
+ object api extends MillPublishScalaModule {
+ def moduleDeps = Seq(build.libs.javalib.testrunner)
+
+ override def compileMvnDeps: T[Seq[Dep]] = Seq(
+ Deps.osLib
+ )
+ }
+
+ object worker extends MillPublishScalaModule {
+ override def compileModuleDeps = Seq(api)
+
+ def mandatoryMvnDeps = Seq.empty[Dep]
+
+ override def compileMvnDeps: T[Seq[Dep]] =
+ super.mandatoryMvnDeps() ++ Seq(
+ Deps.osLib,
+ Deps.groovyCompiler
+ )
+ }
+
+}
diff --git a/libs/groovylib/src/mill/groovylib/GroovyMavenModule.scala b/libs/groovylib/src/mill/groovylib/GroovyMavenModule.scala
new file mode 100644
index 000000000000..46c7ec366602
--- /dev/null
+++ b/libs/groovylib/src/mill/groovylib/GroovyMavenModule.scala
@@ -0,0 +1,21 @@
+package mill.groovylib
+
+import mill.Task
+import mill.javalib.MavenModule
+
+/**
+ * A [[GroovyModule]] with a Maven compatible directory layout:
+ * `src/main/groovy`, `src/main/resources`, etc.
+ */
+@mill.api.experimental
+trait GroovyMavenModule extends GroovyModule with MavenModule {
+ private def sources0 = Task.Sources("src/main/groovy")
+ override def sources = super.sources() ++ sources0()
+
+ trait GroovyMavenTests extends GroovyTests with MavenTests {
+ override def sources = Task.Sources(
+ moduleDir / "src" / testModuleName / "java",
+ moduleDir / "src" / testModuleName / "groovy"
+ )
+ }
+}
diff --git a/libs/groovylib/src/mill/groovylib/GroovyModule.scala b/libs/groovylib/src/mill/groovylib/GroovyModule.scala
new file mode 100644
index 000000000000..25c5af0817ba
--- /dev/null
+++ b/libs/groovylib/src/mill/groovylib/GroovyModule.scala
@@ -0,0 +1,301 @@
+package mill
+package groovylib
+
+import mill.api.{ModuleRef, Result}
+import mill.javalib.api.CompilationResult
+import mill.javalib.api.JvmWorkerApi as PublicJvmWorkerApi
+import mill.api.daemon.internal.{CompileProblemReporter, GroovyModuleApi, internal}
+import mill.javalib.{Dep, JavaModule, JvmWorkerModule, Lib}
+import mill.*
+import mainargs.Flag
+import mill.api.daemon.internal.bsp.{BspBuildTarget, BspModuleApi}
+import mill.groovylib.worker.api.GroovyCompilerConfiguration
+import mill.javalib.api.internal.{JavaCompilerOptions, JvmWorkerApi, ZincCompileJava}
+import mill.util.Version
+
+/**
+ * Core configuration required to compile a single Groovy module.
+ *
+ * Resolves
+ */
+@mill.api.experimental
+trait GroovyModule extends JavaModule with GroovyModuleApi { outer =>
+
+ /**
+ * The Groovy version to be used.
+ */
+ def groovyVersion: T[String]
+
+ /**
+ * The compiler language version. Default is derived from [[groovyVersion]].
+ */
+ def groovyLanguageVersion: T[String] = Task { groovyVersion().split("[.]").take(2).mkString(".") }
+
+ private def useGroovyBom: T[Boolean] = Task {
+ if (groovyVersion().isBlank) {
+ false
+ } else {
+ Version.isAtLeast(groovyVersion(), "4.0.26")(using Version.IgnoreQualifierOrdering)
+ }
+ }
+
+ override def bomMvnDeps: T[Seq[Dep]] = super.bomMvnDeps() ++
+ Seq(groovyVersion())
+ .filter(_.nonEmpty && useGroovyBom())
+ .map(v => mvn"org.apache.groovy:groovy-bom:$v")
+
+ /**
+ * All individual source files fed into the compiler.
+ */
+ override def allSourceFiles: T[Seq[PathRef]] = Task {
+ allGroovySourceFiles() ++ allJavaSourceFiles()
+ }
+
+ /**
+ * Specifiy the bytecode version for the Groovy compiler.
+ * {{{
+ * def groovyCompileTargetBytecode = Some("17")
+ * }}}
+ */
+ def groovyCompileTargetBytecode: T[Option[String]] = None
+
+ /**
+ * Specify if the Groovy compiler should enable preview features.
+ */
+ def groovyCompileEnablePreview: T[Boolean] = false
+
+ /**
+ * Specify which global AST transformations should be disabled. Be aware that transformations
+ * like [[groovy.transform.Immutable]] are so-called "local" transformations and will not be
+ * affected.
+ *
+ * see [[https://docs.groovy-lang.org/latest/html/api/org/codehaus/groovy/control/CompilerConfiguration.html#setDisabledGlobalASTTransformations(java.util.Set) Groovy-Docs]]
+ */
+ def disabledGlobalAstTransformations: T[Set[String]] = Set.empty
+
+ /**
+ * All individual Java source files fed into the compiler.
+ * Subset of [[allSourceFiles]].
+ */
+ private def allJavaSourceFiles: T[Seq[PathRef]] = Task {
+ Lib.findSourceFiles(allSources(), Seq("java")).map(PathRef(_))
+ }
+
+ /**
+ * All individual Groovy source files fed into the compiler.
+ * Subset of [[allSourceFiles]].
+ */
+ private def allGroovySourceFiles: T[Seq[PathRef]] = Task {
+ Lib.findSourceFiles(allSources(), Seq("groovy")).map(PathRef(_))
+ }
+
+ /**
+ * The dependencies of this module.
+ * Defaults to add the Groovy dependency matching the [[groovyVersion]].
+ */
+ override def mandatoryMvnDeps: T[Seq[Dep]] = Task {
+ super.mandatoryMvnDeps() ++ groovyCompilerMvnDeps()
+ }
+
+ def jvmWorkerRef: ModuleRef[JvmWorkerModule] = jvmWorker
+
+ override def checkGradleModules: T[Boolean] = true
+
+ /**
+ * The Java classpath resembling the Groovy compiler.
+ * Default is derived from [[groovyCompilerMvnDeps]].
+ */
+ def groovyCompilerClasspath: T[Seq[PathRef]] = Task {
+ val deps = groovyCompilerMvnDeps() ++ Seq(
+ Dep.millProjectModule("mill-libs-groovylib-worker")
+ )
+ defaultResolver().classpath(
+ deps,
+ resolutionParamsMapOpt = None
+ )
+ }
+
+ /**
+ * The Coursier dependencies resembling the Groovy compiler.
+ *
+ * Default is derived from [[groovyVersion]].
+ */
+ def groovyCompilerMvnDeps: T[Seq[Dep]] = Task {
+ val gv = groovyVersion()
+ Seq(mvn"org.apache.groovy:groovy:$gv")
+ }
+
+ /**
+ * Compiles all the sources to JVM class files.
+ */
+ override def compile: T[CompilationResult] = Task {
+ groovyCompileTask()()
+ }
+
+ def compileGroovyStubs: T[Result[Unit]] = Task(persistent = true) {
+ val groovySourceFiles = allGroovySourceFiles().map(_.path)
+ val stubDir = compileGeneratedGroovyStubs()
+ Task.ctx().log.info(
+ s"Generating Java stubs for ${groovySourceFiles.size} Groovy sources to $stubDir ..."
+ )
+
+ val compileCp = compileClasspath().map(_.path).filter(os.exists)
+ val config = GroovyCompilerConfiguration(
+ enablePreview = groovyCompileEnablePreview(),
+ targetBytecode = groovyCompileTargetBytecode(),
+ disabledGlobalAstTransformations = disabledGlobalAstTransformations()
+ )
+
+ GroovyWorkerManager.groovyWorker().withValue(groovyCompilerClasspath()) {
+ _.compileGroovyStubs(groovySourceFiles, compileCp, stubDir, config)
+ }
+ }
+
+ /**
+ * Path to Java stub sources as part of the `compile` step. Stubs are generated
+ * by the Groovy compiler and later used by the Java compiler.
+ */
+ def compileGeneratedGroovyStubs: T[os.Path] = Task(persistent = true) { Task.dest }
+
+ /**
+ * The actual Groovy compile task (used by [[compile]]).
+ */
+ protected def groovyCompileTask(): Task[CompilationResult] =
+ Task.Anon {
+ val ctx = Task.ctx()
+ val dest = ctx.dest
+ val classes = dest / "classes"
+ os.makeDir.all(classes)
+
+ val javaSourceFiles = allJavaSourceFiles().map(_.path)
+ val groovySourceFiles = allGroovySourceFiles().map(_.path)
+
+ val isGroovy = groovySourceFiles.nonEmpty
+ val isJava = javaSourceFiles.nonEmpty
+ val compileCp = compileClasspath().map(_.path).filter(os.exists)
+ val updateCompileOutput = upstreamCompileOutput()
+
+ sealed trait CompilationStrategy
+ case object JavaOnly extends CompilationStrategy
+ case object GroovyOnly extends CompilationStrategy
+ case object Mixed extends CompilationStrategy
+
+ val strategy: CompilationStrategy = (isJava, isGroovy) match {
+ case (true, false) => JavaOnly
+ case (false, true) => GroovyOnly
+ case (true, true) => Mixed
+ case (false, false) => JavaOnly // fallback, though this shouldn't happen
+ }
+
+ val config = GroovyCompilerConfiguration(
+ enablePreview = groovyCompileEnablePreview(),
+ targetBytecode = groovyCompileTargetBytecode(),
+ disabledGlobalAstTransformations = disabledGlobalAstTransformations()
+ )
+
+ def compileJava: Result[CompilationResult] = {
+ ctx.log.info(
+ s"Compiling ${javaSourceFiles.size} Java sources to $classes ..."
+ )
+ // The compiler step is lazy, but its dependencies are not!
+ internalCompileJavaFiles(
+ worker = jvmWorkerRef().internalWorker(),
+ upstreamCompileOutput = updateCompileOutput,
+ javaSourceFiles = javaSourceFiles,
+ compileCp = compileCp :+ compileGeneratedGroovyStubs(),
+ javaHome = javaHome().map(_.path),
+ javacOptions = javacOptions(),
+ compileProblemReporter = ctx.reporter(hashCode),
+ reportOldProblems = zincReportCachedProblems()
+ )
+ }
+
+ def compileGroovy(): Result[CompilationResult] = {
+ ctx.log.info(
+ s"Compiling ${groovySourceFiles.size} Groovy sources to $classes ..."
+ )
+ val workerGroovyResult =
+ GroovyWorkerManager.groovyWorker().withValue(groovyCompilerClasspath()) {
+ _.compile(groovySourceFiles, compileCp, classes, config)
+ }
+
+ // TODO figure out if there is a better way to do this
+ val analysisFile = dest / "groovy.analysis.dummy" // needed for Mills CompilationResult
+ os.write(target = analysisFile, data = "", createFolders = true)
+
+ workerGroovyResult match {
+ case Result.Success(_) =>
+ CompilationResult(analysisFile, PathRef(classes))
+ case Result.Failure(reason) => Result.Failure(reason)
+ }
+ }
+
+ strategy match {
+ case JavaOnly =>
+ compileJava
+
+ case GroovyOnly =>
+ compileGroovy()
+
+ case Mixed =>
+ compileGroovyStubs()
+ .flatMap(_ => compileJava)
+ .flatMap(_ => compileGroovy())
+ }
+
+ }
+
+ private[groovylib] def internalCompileJavaFiles(
+ worker: JvmWorkerApi,
+ upstreamCompileOutput: Seq[CompilationResult],
+ javaSourceFiles: Seq[os.Path],
+ compileCp: Seq[os.Path],
+ javaHome: Option[os.Path],
+ javacOptions: Seq[String],
+ compileProblemReporter: Option[CompileProblemReporter],
+ reportOldProblems: Boolean
+ )(implicit ctx: PublicJvmWorkerApi.Ctx): Result[CompilationResult] = {
+ val jOpts = JavaCompilerOptions(javacOptions)
+ worker.compileJava(
+ ZincCompileJava(
+ upstreamCompileOutput = upstreamCompileOutput,
+ sources = javaSourceFiles,
+ compileClasspath = compileCp,
+ javacOptions = jOpts.compiler,
+ incrementalCompilation = true
+ ),
+ javaHome = javaHome,
+ javaRuntimeOptions = jOpts.runtime,
+ reporter = compileProblemReporter,
+ reportCachedProblems = reportOldProblems
+ )
+ }
+
+ @internal
+ override def bspBuildTarget: BspBuildTarget = super.bspBuildTarget.copy(
+ languageIds = Seq(
+ BspModuleApi.LanguageId.Java,
+ BspModuleApi.LanguageId.Groovy
+ ),
+ canCompile = true,
+ canRun = true
+ )
+
+ override def prepareOffline(all: Flag): Command[Seq[PathRef]] = Task.Command {
+ (
+ super.prepareOffline(all)() ++
+ groovyCompilerClasspath()
+ ).distinct
+ }
+
+ /**
+ * A test submodule linked to its parent module best suited for unit-tests.
+ */
+ trait GroovyTests extends JavaTests with GroovyModule {
+
+ override def groovyLanguageVersion: T[String] = outer.groovyLanguageVersion()
+ override def groovyVersion: T[String] = Task { outer.groovyVersion() }
+ override def bomMvnDeps: T[Seq[Dep]] = outer.bomMvnDeps()
+ override def mandatoryMvnDeps: Task.Simple[Seq[Dep]] = outer.mandatoryMvnDeps
+ }
+}
diff --git a/libs/groovylib/src/mill/groovylib/GroovyWorkerManager.scala b/libs/groovylib/src/mill/groovylib/GroovyWorkerManager.scala
new file mode 100644
index 000000000000..41a85025df9c
--- /dev/null
+++ b/libs/groovylib/src/mill/groovylib/GroovyWorkerManager.scala
@@ -0,0 +1,42 @@
+package mill.groovylib
+
+import mill.*
+import mill.api.{Discover, ExternalModule, TaskCtx}
+import mill.groovylib.worker.api.GroovyWorker
+import mill.util.ClassLoaderCachedFactory
+
+@mill.api.experimental
+class GroovyWorkerManager()(implicit ctx: TaskCtx)
+ extends ClassLoaderCachedFactory[GroovyWorker](ctx.jobs) {
+
+ def getValue(cl: ClassLoader) = GroovyWorkerManager.get(cl)
+}
+
+object GroovyWorkerManager extends ExternalModule {
+ def groovyWorker: Worker[GroovyWorkerManager] = Task.Worker {
+ new GroovyWorkerManager()
+ }
+
+ def get(toolsClassLoader: ClassLoader)(implicit ctx: TaskCtx): GroovyWorker = {
+ // TODO why not use ServiceLoader...investigate
+ val className =
+ classOf[GroovyWorker].getPackage().getName().split("\\.").dropRight(1).mkString(
+ "."
+ ) + ".impl." + classOf[GroovyWorker].getSimpleName() + "Impl"
+
+ val impl = toolsClassLoader.loadClass(className)
+ val worker = impl.getConstructor().newInstance().asInstanceOf[GroovyWorker]
+ if (worker.getClass().getClassLoader() != toolsClassLoader) {
+ ctx.log.warn(
+ """Worker not loaded from worker classloader.
+ |You should not add the mill-groovy-worker JAR to the mill build classpath""".stripMargin
+ )
+ }
+ if (worker.getClass().getClassLoader() == classOf[GroovyWorker].getClassLoader()) {
+ ctx.log.warn("Worker classloader used to load interface and implementation")
+ }
+ worker
+ }
+
+ override protected def millDiscover: Discover = Discover[this.type]
+}
diff --git a/libs/groovylib/src/mill/groovylib/JavaMavenModuleWithGroovyTests.scala b/libs/groovylib/src/mill/groovylib/JavaMavenModuleWithGroovyTests.scala
new file mode 100644
index 000000000000..6f2c4ce7f391
--- /dev/null
+++ b/libs/groovylib/src/mill/groovylib/JavaMavenModuleWithGroovyTests.scala
@@ -0,0 +1,35 @@
+package mill.groovylib
+
+import mill.*
+import mill.javalib.{JavaModule, MavenModule}
+
+/**
+ * Convenience trait for projects using Java for production and Groovy for tests in a Maven setup.
+ *
+ * Since [[GroovyModule.GroovyTests]] is only available as a child-trait, it is necessary to have
+ * the main module as a [[GroovyModule]], which would implicitly add Groovy dependencies to the
+ * module.
+ * This trait explicitly uses Java with a Maven layout for the main module and enables `src/test/groovy`
+ * as a source folder for Groovy tests.
+ *
+ * {{{
+ * object `package` extends JavaMavenModuleWithGroovyTests {
+ *
+ * object `test` extends GroovyMavenTests with TestModule.Spock {
+ * override def groovyVersion: T[String] = "4.0.28"
+ * override def spockVersion: T[String] = "2.3-groovy-4"
+ * }
+ * }
+ * }}}
+ *
+ * Note: for non-Maven layouts this is not necessary, since the test module can just be a [[GroovyModule]].
+ */
+@mill.api.experimental
+trait JavaMavenModuleWithGroovyTests extends JavaModule with MavenModule {
+
+ trait GroovyMavenTests extends JavaTests with MavenTests with GroovyModule {
+ private def groovyTestSources = Task.Sources(moduleDir / "src" / testModuleName / "groovy")
+ override def sources: T[Seq[PathRef]] = super[MavenTests].sources() ++ groovyTestSources()
+ override def resources: T[Seq[PathRef]] = super[MavenTests].resources()
+ }
+}
diff --git a/libs/groovylib/src/mill/groovylib/exports.scala b/libs/groovylib/src/mill/groovylib/exports.scala
new file mode 100644
index 000000000000..89d445c8033c
--- /dev/null
+++ b/libs/groovylib/src/mill/groovylib/exports.scala
@@ -0,0 +1,17 @@
+package mill.groovylib
+
+export mill.javalib.DepSyntax
+
+export mill.javalib.Dep
+
+export mill.javalib.JavaModule
+
+export mill.javalib.MavenModule
+
+export mill.javalib.TestModule
+
+export mill.javalib.PublishModule
+
+export mill.javalib.NativeImageModule
+
+export mill.javalib.JvmWorkerModule
diff --git a/libs/groovylib/src/mill/groovylib/groovylib.scala b/libs/groovylib/src/mill/groovylib/groovylib.scala
new file mode 100644
index 000000000000..436a35a541bd
--- /dev/null
+++ b/libs/groovylib/src/mill/groovylib/groovylib.scala
@@ -0,0 +1,7 @@
+package mill
+
+/**
+ * Groovy toolchain containing [[GroovyModule]] and other functionality related to building
+ * Groovy projects.
+ */
+package object groovylib
diff --git a/libs/groovylib/src/mill/groovylib/publish/exports.scala b/libs/groovylib/src/mill/groovylib/publish/exports.scala
new file mode 100644
index 000000000000..0c01da54c719
--- /dev/null
+++ b/libs/groovylib/src/mill/groovylib/publish/exports.scala
@@ -0,0 +1,35 @@
+package mill.groovylib.publish
+
+export mill.javalib.publish.Ivy
+
+export mill.javalib.publish.License
+
+export mill.javalib.publish.LocalIvyPublisher
+
+export mill.javalib.publish.LocalM2Publisher
+
+export mill.javalib.publish.Pom
+
+export mill.javalib.publish.PublishInfo
+
+export mill.javalib.publish.Artifact
+
+export mill.javalib.publish.Scope
+
+export mill.javalib.publish.Dependency
+
+export mill.javalib.publish.Developer
+
+export mill.javalib.publish.PomSettings
+
+export mill.javalib.publish.PackagingType
+
+export mill.javalib.publish.SonatypeHelpers
+export mill.javalib.publish.SonatypeHttpApi
+export mill.javalib.SonatypeCentralPublisher
+
+export mill.javalib.publish.VersionControl
+
+export mill.javalib.publish.VersionControlConnection
+
+export mill.javalib.publish.VersionScheme
diff --git a/libs/groovylib/test/resources/hello-groovy/groovy-tests/src/test/groovy/HelloMavenTestOnly.groovy b/libs/groovylib/test/resources/hello-groovy/groovy-tests/src/test/groovy/HelloMavenTestOnly.groovy
new file mode 100644
index 000000000000..3f456065bfa4
--- /dev/null
+++ b/libs/groovylib/test/resources/hello-groovy/groovy-tests/src/test/groovy/HelloMavenTestOnly.groovy
@@ -0,0 +1,12 @@
+package hello.maven.tests
+
+//import hello.Hello
+import org.junit.jupiter.api.Test
+import static org.junit.jupiter.api.Assertions.assertEquals
+
+class HelloMavenTestOnly {
+ @Test
+ void testSuccess() {
+ assertEquals(true, true)
+ }
+}
diff --git a/libs/groovylib/test/resources/hello-groovy/main/compileroptions/src/HelloCompilerOptions.groovy b/libs/groovylib/test/resources/hello-groovy/main/compileroptions/src/HelloCompilerOptions.groovy
new file mode 100644
index 000000000000..4033f7ae1fde
--- /dev/null
+++ b/libs/groovylib/test/resources/hello-groovy/main/compileroptions/src/HelloCompilerOptions.groovy
@@ -0,0 +1,12 @@
+package compileroptions
+
+class HelloCompilerOptions {
+
+ static String getHelloString() {
+ return "Hello, Java 11 Preview!"
+ }
+
+ static void main(String[] args) {
+ println(getHelloString())
+ }
+}
\ No newline at end of file
diff --git a/libs/groovylib/test/resources/hello-groovy/main/joint-compile/src/GroovyGreeter.groovy b/libs/groovylib/test/resources/hello-groovy/main/joint-compile/src/GroovyGreeter.groovy
new file mode 100644
index 000000000000..d876ed9310c3
--- /dev/null
+++ b/libs/groovylib/test/resources/hello-groovy/main/joint-compile/src/GroovyGreeter.groovy
@@ -0,0 +1,18 @@
+package jointcompile
+
+class GroovyGreeter{
+
+ private final String toGreet;
+ private final JavaPrinter printer;
+
+ GroovyGreeter(String toGreet){
+ this.toGreet = toGreet
+ this.printer = new JavaPrinter()
+ }
+
+
+ void greet(){
+ printer.print("Hello $toGreet");
+ }
+
+}
\ No newline at end of file
diff --git a/libs/groovylib/test/resources/hello-groovy/main/joint-compile/src/JavaMain.java b/libs/groovylib/test/resources/hello-groovy/main/joint-compile/src/JavaMain.java
new file mode 100644
index 000000000000..6522149af6c4
--- /dev/null
+++ b/libs/groovylib/test/resources/hello-groovy/main/joint-compile/src/JavaMain.java
@@ -0,0 +1,9 @@
+package jointcompile;
+
+public class JavaMain {
+
+ public static void main(String[] args) {
+ var greeter = new GroovyGreeter("JointCompile");
+ greeter.greet();
+ }
+}
diff --git a/libs/groovylib/test/resources/hello-groovy/main/joint-compile/src/JavaPrinter.java b/libs/groovylib/test/resources/hello-groovy/main/joint-compile/src/JavaPrinter.java
new file mode 100644
index 000000000000..eb6dcfd64e19
--- /dev/null
+++ b/libs/groovylib/test/resources/hello-groovy/main/joint-compile/src/JavaPrinter.java
@@ -0,0 +1,7 @@
+package jointcompile;
+
+public class JavaPrinter {
+ public void print(String s) {
+ System.out.println(s);
+ }
+}
diff --git a/libs/groovylib/test/resources/hello-groovy/main/script/src/HelloScript.groovy b/libs/groovylib/test/resources/hello-groovy/main/script/src/HelloScript.groovy
new file mode 100644
index 000000000000..ccdd2a9a93e9
--- /dev/null
+++ b/libs/groovylib/test/resources/hello-groovy/main/script/src/HelloScript.groovy
@@ -0,0 +1,7 @@
+
+println getHelloString()
+
+static String getHelloString() {
+ return "Hello, Scripting!"
+}
+
diff --git a/libs/groovylib/test/resources/hello-groovy/main/src/Hello.groovy b/libs/groovylib/test/resources/hello-groovy/main/src/Hello.groovy
new file mode 100644
index 000000000000..c6d16c431c32
--- /dev/null
+++ b/libs/groovylib/test/resources/hello-groovy/main/src/Hello.groovy
@@ -0,0 +1,16 @@
+package hello
+
+class Hello {
+
+ static String getHelloString() {
+ return "Hello, world!"
+ }
+
+ static String sayHello(String name){
+ return "Hello, $name"
+ }
+
+ static void main(String[] args) {
+ println(getHelloString())
+ }
+}
\ No newline at end of file
diff --git a/libs/groovylib/test/resources/hello-groovy/main/staticcompile/src/HelloStatic.groovy b/libs/groovylib/test/resources/hello-groovy/main/staticcompile/src/HelloStatic.groovy
new file mode 100644
index 000000000000..fe56e2263888
--- /dev/null
+++ b/libs/groovylib/test/resources/hello-groovy/main/staticcompile/src/HelloStatic.groovy
@@ -0,0 +1,26 @@
+package hellostatic
+
+import groovy.transform.CompileStatic
+
+@CompileStatic
+class HelloStatic {
+
+ static void main(String[] args) {
+ def x = new Some("Hello World")
+ x.doStuff()
+ }
+}
+
+@CompileStatic
+class Some {
+
+ private String toPrint;
+
+ Some(String toPrint){
+ this.toPrint = toPrint
+ }
+
+ void doStuff(){
+ println(toPrint)
+ }
+}
\ No newline at end of file
diff --git a/libs/groovylib/test/resources/hello-groovy/main/test/src/HelloTest.groovy b/libs/groovylib/test/resources/hello-groovy/main/test/src/HelloTest.groovy
new file mode 100644
index 000000000000..5d9833fe9585
--- /dev/null
+++ b/libs/groovylib/test/resources/hello-groovy/main/test/src/HelloTest.groovy
@@ -0,0 +1,12 @@
+package hello.tests
+
+import hello.Hello
+import org.junit.jupiter.api.Test
+import static org.junit.jupiter.api.Assertions.assertEquals
+
+class HelloTest {
+ @Test
+ void testSuccess() {
+ assertEquals("Hello, world!", Hello.getHelloString())
+ }
+}
diff --git a/libs/groovylib/test/resources/hello-groovy/spock/src/Hello.groovy b/libs/groovylib/test/resources/hello-groovy/spock/src/Hello.groovy
new file mode 100644
index 000000000000..c6d16c431c32
--- /dev/null
+++ b/libs/groovylib/test/resources/hello-groovy/spock/src/Hello.groovy
@@ -0,0 +1,16 @@
+package hello
+
+class Hello {
+
+ static String getHelloString() {
+ return "Hello, world!"
+ }
+
+ static String sayHello(String name){
+ return "Hello, $name"
+ }
+
+ static void main(String[] args) {
+ println(getHelloString())
+ }
+}
\ No newline at end of file
diff --git a/libs/groovylib/test/resources/hello-groovy/spock/tests/src/SpockTest.groovy b/libs/groovylib/test/resources/hello-groovy/spock/tests/src/SpockTest.groovy
new file mode 100644
index 000000000000..6a4ef42f4a76
--- /dev/null
+++ b/libs/groovylib/test/resources/hello-groovy/spock/tests/src/SpockTest.groovy
@@ -0,0 +1,21 @@
+package hello.spock
+
+import spock.lang.Specification
+import static hello.Hello.*
+
+class SpockTest extends Specification {
+
+ def "test succeeds"() {
+ expect:
+ getHelloString() == "Hello, world!"
+ }
+
+ def "sayHello to '#name' equals '#expected'"() {
+ expect:
+ sayHello(name) == expected
+
+ where:
+ name << ["Foo", "Bar"]
+ expected = "Hello, $name"
+ }
+}
diff --git a/libs/groovylib/test/src/mill/groovylib/HelloGroovyTests.scala b/libs/groovylib/test/src/mill/groovylib/HelloGroovyTests.scala
new file mode 100644
index 000000000000..6a07da866c30
--- /dev/null
+++ b/libs/groovylib/test/src/mill/groovylib/HelloGroovyTests.scala
@@ -0,0 +1,331 @@
+package mill
+package groovylib
+
+import mill.javalib.{JavaModule, TestModule}
+import mill.api.Task
+import mill.api.Discover
+import mill.testkit.{TestRootModule, UnitTester}
+import utest.*
+
+import java.io.FileInputStream
+
+object HelloGroovyTests extends TestSuite {
+
+ val groovy4Version = "4.0.28"
+ val groovy5Version = "5.0.1"
+ val groovyVersions = Seq(groovy4Version, groovy5Version)
+ val junit5Version = sys.props.getOrElse("TEST_JUNIT5_VERSION", "5.13.4")
+ val spockGroovy4Version = "2.3-groovy-4.0"
+
+ object HelloGroovy extends TestRootModule {
+
+ trait GroovyVersionCross extends GroovyModule with Cross.Module[String] {
+ override def groovyVersion: Task.Simple[String] = crossValue
+ }
+
+ lazy val millDiscover = Discover[this.type]
+
+ // needed for a special test where only the tests are written in Groovy while appcode remains Java
+ object `groovy-tests` extends JavaMavenModuleWithGroovyTests {
+
+ object `test` extends GroovyMavenTests with TestModule.Junit5 {
+
+ override def moduleDeps: Seq[JavaModule] = Seq(
+ HelloGroovy.`groovy-tests`
+ )
+
+ override def groovyVersion: T[String] = groovy4Version
+ override def jupiterVersion: T[String] = junit5Version
+ }
+
+ }
+
+ /**
+ * Currently Spock does not support Groovy 5, so that's why it's currently
+ * pulled out of the cross compilation.
+ */
+ object spock extends GroovyModule {
+ override def groovyVersion: T[String] = groovy4Version
+
+ object tests extends GroovyTests with TestModule.Spock {
+ override def jupiterVersion: T[String] = junit5Version
+ override def spockVersion: T[String] = spockGroovy4Version
+ }
+ }
+
+ /**
+ * Test to verify BOM-resolution only done starting with the minimal version
+ */
+ object deps extends Module {
+
+ object groovyBom extends GroovyModule {
+ override def groovyVersion: T[String] = groovy4Version
+ }
+
+ object groovyNoBom extends GroovyModule {
+ // Groovy-BOM available starting with 4.0.26
+ override def groovyVersion: T[String] = "4.0.25"
+ }
+
+ object `spockBom` extends GroovyModule with TestModule.Spock {
+ override def spockVersion: T[String] = spockGroovy4Version
+ override def groovyVersion: T[String] = groovy4Version
+ }
+
+ object `spockNoBom` extends GroovyModule with TestModule.Spock {
+ // Groovy-BOM available starting with 2.3
+ override def spockVersion: T[String] = "2.2-groovy-4.0"
+ override def groovyVersion: T[String] = groovy4Version
+ }
+
+ }
+
+ trait Test extends GroovyVersionCross {
+
+ override def mainClass = Some("hello.Hello")
+
+ object script extends GroovyModule {
+ override def groovyVersion: T[String] = crossValue
+ override def mainClass = Some("HelloScript")
+ }
+
+ object staticcompile extends GroovyModule {
+ override def groovyVersion: T[String] = crossValue
+ override def mainClass = Some("hellostatic.HelloStatic")
+ }
+
+ object `joint-compile` extends GroovyModule {
+ override def groovyVersion: T[String] = crossValue
+ override def mainClass = Some("jointcompile.JavaMain")
+ }
+
+ object test extends GroovyTests with TestModule.Junit5 {
+ override def jupiterVersion: T[String] = junit5Version
+ override def junitPlatformVersion = "1.13.4"
+ }
+
+ object compileroptions extends GroovyModule {
+ def javaVersion = "11"
+ override def groovyVersion: T[String] = crossValue
+ override def groovyCompileTargetBytecode: Task.Simple[Option[String]] = Some(javaVersion)
+ override def groovyCompileEnablePreview: Task.Simple[Boolean] = true
+ override def mainClass = Some("compileroptions.HelloCompilerOptions")
+ }
+ }
+ object main extends Cross[Test](groovyVersions)
+ }
+
+ val resourcePath = os.Path(sys.env("MILL_TEST_RESOURCE_DIR")) / "hello-groovy"
+
+ /**
+ * Compiles test files located within resources
+ */
+ def testEval() = UnitTester(HelloGroovy, resourcePath)
+
+ def tests: Tests = Tests {
+
+ def main = HelloGroovy.main
+ def spock = HelloGroovy.spock
+ def mixed = HelloGroovy.`groovy-tests`
+ def deps = HelloGroovy.deps
+
+ test("running a Groovy script") {
+ testEval().scoped { eval =>
+ main.crossModules.foreach(m => {
+ val Right(_) = eval.apply(m.script.run()): @unchecked
+ })
+ }
+ }
+
+ test("running a Groovy script") {
+ testEval().scoped { eval =>
+ main.crossModules.foreach(m => {
+ val Right(_) = eval.apply(m.script.run()): @unchecked
+ })
+ }
+ }
+
+ test("compile & run Groovy module") {
+ testEval().scoped { eval =>
+ main.crossModules.foreach(m => {
+ val Right(result) = eval.apply(m.compile): @unchecked
+
+ assert(
+ os.walk(result.value.classes.path).exists(_.last == "Hello.class")
+ )
+
+ val Right(_) = eval.apply(m.run()): @unchecked
+ })
+ }
+ }
+
+ test("compile & run Groovy JUnit5 test") {
+ testEval().scoped { eval =>
+ main.crossModules.foreach(m => {
+ val Right(result) = eval.apply(m.test.compile): @unchecked
+
+ assert(
+ os.walk(result.value.classes.path).exists(_.last == "HelloTest.class")
+ )
+
+ val Right(discovered) = eval.apply(m.test.discoveredTestClasses): @unchecked
+ assert(discovered.value == Seq("hello.tests.HelloTest"))
+
+ val Right(_) = eval.apply(m.test.testForked()): @unchecked
+ })
+ }
+ }
+
+ test("compile & run a statically compiled Groovy") {
+ testEval().scoped { eval =>
+ main.crossModules.foreach(m => {
+ val Right(result) = eval.apply(m.staticcompile.compile): @unchecked
+ assert(
+ os.walk(result.value.classes.path).exists(_.last == "HelloStatic.class")
+ )
+ val Right(_) = eval.apply(m.staticcompile.run()): @unchecked
+ })
+ }
+ }
+
+ test("compile joint (groovy <-> java cycle) & run") {
+ testEval().scoped { eval =>
+ main.crossModules.foreach(m => {
+ val Right(result) = eval.apply(m.`joint-compile`.compile): @unchecked
+
+ assert(
+ os.walk(result.value.classes.path).exists(_.last == "JavaPrinter.class")
+ )
+ assert(
+ os.walk(result.value.classes.path).exists(_.last == "GroovyGreeter.class")
+ )
+
+ val Right(_) = eval.apply(m.`joint-compile`.run()): @unchecked
+ })
+ }
+ }
+
+ test("compiles to Java 11 with Preview enabled") {
+ import org.objectweb.asm.ClassReader
+
+ case class BytecodeVersion(major: Int, minor: Int) {
+ def javaVersion: String = major match {
+ case 55 => "11"
+ case _ => "Irrelevant"
+ }
+
+ def is11PreviewEnabled: Boolean = minor == 65535 // 0xFFFF
+ }
+
+ def getBytecodeVersion(classFilePath: os.Path): BytecodeVersion = {
+ val classReader = new ClassReader(new FileInputStream(classFilePath.toIO))
+ val buffer = classReader.b
+
+ // see https://en.wikipedia.org/wiki/Java_class_file#General_layout
+ // Class file format: magic(4) + minor(2) + major(2) + ...
+ val minor = ((buffer(4) & 0xff) << 8) | (buffer(5) & 0xff)
+ val major = ((buffer(6) & 0xff) << 8) | (buffer(7) & 0xff)
+
+ BytecodeVersion(major, minor)
+ }
+
+ testEval().scoped { eval =>
+ main.crossModules.foreach(m => {
+ val Right(result) = eval.apply(m.compileroptions.compile): @unchecked
+
+ val compiledClassFile =
+ os.walk(result.value.classes.path).find(_.last == "HelloCompilerOptions.class")
+
+ assert(
+ compiledClassFile.isDefined
+ )
+
+ val bytecodeVersion = getBytecodeVersion(compiledClassFile.get)
+
+ assert(bytecodeVersion.javaVersion == m.compileroptions.javaVersion)
+ assert(bytecodeVersion.is11PreviewEnabled)
+
+ val Right(_) = eval.apply(m.compileroptions.run()): @unchecked
+ })
+ }
+ }
+
+ test("compile & test module (only test uses Groovy)") {
+ testEval().scoped { eval =>
+
+ val Right(_) = eval.apply(mixed.test.compile): @unchecked
+ val Right(discovered) = eval.apply(mixed.test.discoveredTestClasses): @unchecked
+ assert(discovered.value == Seq("hello.maven.tests.HelloMavenTestOnly"))
+
+ val Right(_) = eval.apply(mixed.test.testForked()): @unchecked
+ }
+ }
+
+ test("compile & run Spock test") {
+ testEval().scoped { eval =>
+ val Right(result1) = eval.apply(spock.tests.compile): @unchecked
+ assert(
+ os.walk(result1.value.classes.path).exists(_.last == "SpockTest.class")
+ )
+
+ val Right(discovered) = eval.apply(spock.tests.discoveredTestClasses): @unchecked
+ assert(discovered.value == Seq("hello.spock.SpockTest"))
+
+ val Right(_) = eval.apply(spock.tests.testForked()): @unchecked
+ }
+ }
+
+ test("dependency management") {
+
+ test("groovy") {
+
+ val groovyBom = mvn"org.apache.groovy:groovy-bom:$groovy4Version"
+
+ test("groovy bom is added when version is at least 4.0.26") {
+ testEval().scoped { eval =>
+ val Right(result) = eval.apply(deps.groovyBom.bomMvnDeps): @unchecked
+
+ assert(
+ result.value.contains(groovyBom)
+ )
+ }
+ }
+
+ test("groovy bom is NOT added when version is below 4.0.26") {
+ testEval().scoped { eval =>
+ val Right(result) = eval.apply(deps.groovyNoBom.bomMvnDeps): @unchecked
+
+ assert(
+ !result.value.contains(groovyBom)
+ )
+ }
+ }
+ }
+
+ test("spock") {
+
+ val spockBom = mvn"org.spockframework:spock-bom:$spockGroovy4Version"
+
+ test("spock bom is added when version is at least 2.3") {
+ testEval().scoped { eval =>
+ val Right(result) = eval.apply(deps.spockBom.bomMvnDeps): @unchecked
+
+ assert(
+ result.value.contains(spockBom)
+ )
+ }
+ }
+
+ test("spock bom is NOT added when version is below 2.3") {
+ testEval().scoped { eval =>
+ val Right(result) = eval.apply(deps.spockNoBom.bomMvnDeps): @unchecked
+
+ assert(
+ !result.value.contains(spockBom)
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/libs/groovylib/worker/src/mill/groovylib/worker/impl/GroovyWorkerImpl.scala b/libs/groovylib/worker/src/mill/groovylib/worker/impl/GroovyWorkerImpl.scala
new file mode 100644
index 000000000000..85c48f3f05cc
--- /dev/null
+++ b/libs/groovylib/worker/src/mill/groovylib/worker/impl/GroovyWorkerImpl.scala
@@ -0,0 +1,95 @@
+package mill.groovylib.worker.impl
+
+import groovy.lang.GroovyClassLoader
+import mill.api.{Result, TaskCtx}
+import mill.javalib.api.CompilationResult
+import mill.groovylib.worker.api.{GroovyCompilerConfiguration, GroovyWorker}
+import org.codehaus.groovy.control.{CompilationUnit, CompilerConfiguration, Phases}
+import org.codehaus.groovy.tools.javac.JavaStubCompilationUnit
+import os.Path
+
+import scala.jdk.CollectionConverters.*
+import scala.util.Try
+
+class GroovyWorkerImpl extends GroovyWorker {
+
+ override def compileGroovyStubs(
+ sourceFiles: Seq[Path],
+ classpath: Seq[Path],
+ outputDir: Path,
+ config: GroovyCompilerConfiguration
+ )(implicit ctx: TaskCtx): Result[Unit] = {
+ val compilerConfig = new CompilerConfiguration()
+ compilerConfig.setTargetDirectory(outputDir.toIO)
+ compilerConfig.setClasspathList(classpath.map(_.toIO.getAbsolutePath).asJava)
+ compilerConfig.setJointCompilationOptions(Map(
+ "stubDir" -> outputDir.toIO,
+ "keepStubs" -> true
+ ).asJava)
+ compilerConfig.setDisabledGlobalASTTransformations(
+ config.disabledGlobalAstTransformations.asJava
+ )
+ compilerConfig.setPreviewFeatures(config.enablePreview)
+ config.targetBytecode.foreach(compilerConfig.setTargetBytecode)
+
+ // we need to set the classloader for groovy to use the worker classloader
+ val parentCl: ClassLoader = this.getClass.getClassLoader
+ // compilerConfig in the GroovyClassLoader is needed when the CL itself is compiling classes
+ val gcl = new GroovyClassLoader(parentCl, compilerConfig)
+ // compilerConfig for actual compilation
+ val stubUnit = JavaStubCompilationUnit(compilerConfig, gcl)
+
+ sourceFiles.foreach { sourceFile =>
+ stubUnit.addSource(sourceFile.toIO)
+ }
+
+ Try {
+ stubUnit.compile(Phases.CONVERSION)
+ }.fold(
+ exception => Result.Failure(s"Groovy stub generation failed: ${exception.getMessage}"),
+ result => Result.Success(())
+ )
+
+ }
+
+ def compile(
+ sourceFiles: Seq[os.Path],
+ classpath: Seq[os.Path],
+ outputDir: os.Path,
+ config: GroovyCompilerConfiguration
+ )(implicit
+ ctx: TaskCtx
+ ): Result[CompilationResult] = {
+
+ val extendedClasspath = classpath :+ outputDir
+
+ val compilerConfig = new CompilerConfiguration()
+ compilerConfig.setTargetDirectory(outputDir.toIO)
+ compilerConfig.setClasspathList(extendedClasspath.map(_.toIO.getAbsolutePath).asJava)
+ compilerConfig.setDisabledGlobalASTTransformations(
+ config.disabledGlobalAstTransformations.asJava
+ )
+ compilerConfig.setPreviewFeatures(config.enablePreview)
+ config.targetBytecode.foreach(compilerConfig.setTargetBytecode)
+
+ // we need to set the classloader for groovy to use the worker classloader
+ val parentCl: ClassLoader = this.getClass.getClassLoader
+ // compilerConfig in the GroovyClassLoader is needed when the CL itself is compiling classes
+ val gcl = new GroovyClassLoader(parentCl, compilerConfig)
+
+ // compilerConfig for actual compilation
+ val unit = new CompilationUnit(compilerConfig, null, gcl)
+
+ sourceFiles.foreach { sourceFile =>
+ unit.addSource(sourceFile.toIO)
+ }
+
+ Try {
+ unit.compile(Phases.OUTPUT)
+ CompilationResult(outputDir, mill.api.PathRef(outputDir))
+ }.fold(
+ exception => Result.Failure(s"Groovy compilation failed: ${exception.getMessage}"),
+ result => Result.Success(result)
+ )
+ }
+}
diff --git a/libs/javalib/src/mill/javalib/TestModule.scala b/libs/javalib/src/mill/javalib/TestModule.scala
index 9f0a0a2fdb86..8bcba0688842 100644
--- a/libs/javalib/src/mill/javalib/TestModule.scala
+++ b/libs/javalib/src/mill/javalib/TestModule.scala
@@ -2,15 +2,14 @@ package mill.javalib
import mill.T
import mill.api.Result
-import mill.api.daemon.internal.TestModuleApi
-import mill.api.daemon.internal.TestReporter
+import mill.api.daemon.internal.{TestModuleApi, TestReporter}
import mill.api.daemon.internal.bsp.{BspBuildTarget, BspModuleApi}
import mill.api.PathRef
import mill.api.Task
import mill.api.TaskCtx
import mill.api.DefaultTaskModule
import mill.javalib.bsp.BspModule
-import mill.util.Jvm
+import mill.util.{Jvm, Version}
import mill.api.JsonFormatters.given
import mill.constants.EnvVars
import mill.javalib.testrunner.{
@@ -58,6 +57,7 @@ trait TestModule
* - [[TestModule.Utest]]
* - [[TestModule.Weaver]]
* - [[TestModule.ZioTest]]
+ * - [[TestModule.Spock]]
*
* Most of these provide additional `xxxVersion` tasks, to manage the test framework dependencies for you.
*/
@@ -358,24 +358,53 @@ object TestModule {
* You can override the [[junitPlatformVersion]] and [[jupiterVersion]] task
* or provide the JUnit 5-dependencies yourself.
*
+ * In case the [[jupiterVersion]] is set (and it is > 5.12), it pulls in JUnit-BOM in [[bomMvnDeps]]. If this is
+ * true, then there is no need to specify the [[junitPlatformVersion]] anymore, because this is managed by the
+ * BOM.
+ *
* See: https://junit.org/junit5/
*/
trait Junit5 extends TestModule {
- /** The JUnit 5 Platfrom version to use, or empty, if you want to provide the dependencies yourself. */
+ /** The JUnit 5 Platform version to use, or empty, if you want to provide the dependencies yourself. */
def junitPlatformVersion: T[String] = Task { "" }
- /** The JUnit Jupiter version to use, or empty, if you want to provide the dependencie yourself. */
+ /** The JUnit Jupiter version to use, or empty, if you want to provide the dependencies yourself. */
def jupiterVersion: T[String] = Task { "" }
+ private def useJupiterBom: T[Boolean] = Task {
+ if (jupiterVersion().isBlank) {
+ false
+ } else {
+ Version.isAtLeast(jupiterVersion(), "5.12.0")(using Version.IgnoreQualifierOrdering)
+ }
+ }
+
override def testFramework: T[String] = "com.github.sbt.junit.jupiter.api.JupiterFramework"
+ override def bomMvnDeps: T[Seq[Dep]] = Task {
+ super.bomMvnDeps() ++
+ Seq(jupiterVersion())
+ .filter(!_.isBlank() && useJupiterBom())
+ .flatMap(v =>
+ Seq(
+ mvn"org.junit:junit-bom:${v.trim()}"
+ )
+ )
+ }
+
override def mandatoryMvnDeps: T[Seq[Dep]] = Task {
super.mandatoryMvnDeps() ++
Seq(mvn"${mill.javalib.api.Versions.jupiterInterface}") ++
- Seq(junitPlatformVersion())
- .filter(!_.isBlank())
- .map(v => mvn"org.junit.platform:junit-platform-launcher:${v.trim()}") ++
+ Seq(junitPlatformVersion()).flatMap(v => {
+ if (!v.isBlank) {
+ Some(mvn"org.junit.platform:junit-platform-launcher:${v.trim()}")
+ } else if (useJupiterBom()) {
+ Some(mvn"org.junit.platform:junit-platform-launcher")
+ } else {
+ None
+ }
+ }) ++
Seq(jupiterVersion())
.filter(!_.isBlank())
.map(v => mvn"org.junit.jupiter:junit-jupiter-api:${v.trim()}")
@@ -600,6 +629,51 @@ object TestModule {
}
}
+ /**
+ * TestModule that uses Spock Test Framework to run tests.
+ * You can override the [[spockVersion]] task or provide the Spock dependency yourself.
+ *
+ * In case the version is set, it pulls in Spock-BOM in [[bomMvnDeps]] (only for 2.3 onwards)
+ * and Spock-Core in [[mvnDeps]]
+ */
+ trait Spock extends TestModule.Junit5 {
+
+ /** The Spock Test version to use, or the empty string, if you want to provide the Spock test dependency yourself. */
+ def spockVersion: T[String] = Task {
+ ""
+ }
+
+ private def isSpockBomAvailable: T[Boolean] = Task {
+ if (spockVersion().isBlank) {
+ false
+ } else {
+ Version.isAtLeast(spockVersion(), "2.3")(using Version.IgnoreQualifierOrdering)
+ }
+ }
+
+ override def bomMvnDeps: T[Seq[Dep]] = Task {
+ super.bomMvnDeps() ++
+ Seq(spockVersion())
+ .filter(!_.isBlank() && isSpockBomAvailable())
+ .flatMap(v =>
+ Seq(
+ mvn"org.spockframework:spock-bom:${v.trim()}"
+ )
+ )
+ }
+
+ override def mandatoryMvnDeps: T[Seq[Dep]] = Task {
+ super.mandatoryMvnDeps() ++
+ Seq(spockVersion())
+ .filter(!_.isBlank())
+ .flatMap(v =>
+ Seq(
+ mvn"org.spockframework:spock-core:${v.trim()}"
+ )
+ )
+ }
+ }
+
def handleResults(
doneMsg: String,
results: Seq[TestResult],
@@ -620,6 +694,7 @@ object TestModule {
def mvnDeps: T[Seq[Dep]] = Seq()
def mandatoryMvnDeps: T[Seq[Dep]] = Seq()
def resources: T[Seq[PathRef]] = Task { Seq.empty[PathRef] }
+ def bomMvnDeps: T[Seq[Dep]] = Seq()
}
trait ScalaModuleBase extends mill.Module {
diff --git a/libs/javalib/test/src/mill/javalib/junit5/JUnit5Tests.scala b/libs/javalib/test/src/mill/javalib/junit5/JUnit5Tests.scala
index 227b5c4aaf09..35d28e33bed0 100644
--- a/libs/javalib/test/src/mill/javalib/junit5/JUnit5Tests.scala
+++ b/libs/javalib/test/src/mill/javalib/junit5/JUnit5Tests.scala
@@ -1,17 +1,34 @@
package mill.javalib.junit5
-import mill.api.Discover
+import mill.api.Task.Simple
+import mill.api.{Discover, Module, Task}
import mill.javalib.JavaModule
import mill.javalib.TestModule
+import mill.javalib.DepSyntax
import mill.testkit.{TestRootModule, UnitTester}
import utest.*
import mill.util.TokenReaders._
object JUnit5Tests extends TestSuite {
+ val junitVersion = "5.13.4"
+
object module extends TestRootModule with JavaModule {
object test extends JavaTests with TestModule.Junit5
lazy val millDiscover = Discover[this.type]
+
+ object deps extends Module {
+
+ object junitBom extends JavaTests with TestModule.Junit5 {
+ override def jupiterVersion: Simple[String] = junitVersion
+ }
+
+ object junitNoBom extends JavaTests with TestModule.Junit5 {
+ // JUnit-BOM available starting with 5.12.0
+ override def jupiterVersion: Simple[String] = "5.11.0"
+ }
+ }
+
}
val testModuleSourcesPath = os.Path(sys.env("MILL_TEST_RESOURCE_DIR")) / "junit5"
@@ -32,5 +49,48 @@ object JUnit5Tests extends TestSuite {
assert(qualifiedNames.forall(_.fullyQualifiedName == "qux.QuxTests"))
}
}
+ test("dependency management") {
+ def testEval() = UnitTester(module, testModuleSourcesPath)
+ val junitBom = mvn"org.junit:junit-bom:$junitVersion"
+ val jupiter = mvn"org.junit.jupiter:junit-jupiter-api:$junitVersion"
+ val junitPlatformLauncher = mvn"org.junit.platform:junit-platform-launcher"
+
+ test("jupiter added when version is set") {
+ testEval().scoped { eval =>
+ val Right(resultDeps) = eval.apply(module.deps.junitBom.mandatoryMvnDeps): @unchecked
+ assert(
+ resultDeps.value.contains(jupiter)
+ )
+ }
+ }
+
+ test("junit bom & platform are added when version is at least 5.12.0") {
+ testEval().scoped { eval =>
+ val Right(resultBom) = eval.apply(module.deps.junitBom.bomMvnDeps): @unchecked
+ assert(
+ resultBom.value.contains(junitBom)
+ )
+
+ val Right(resultDeps) = eval.apply(module.deps.junitBom.mandatoryMvnDeps): @unchecked
+ assert(
+ resultDeps.value.contains(junitPlatformLauncher)
+ )
+ }
+ }
+
+ test("junit bom & platform are NOT added when version is below 5.11.0") {
+ testEval().scoped { eval =>
+ val Right(resultBom) = eval.apply(module.deps.junitNoBom.bomMvnDeps): @unchecked
+ assert(
+ !resultBom.value.contains(junitBom)
+ )
+
+ val Right(resultDeps) = eval.apply(module.deps.junitNoBom.mandatoryMvnDeps): @unchecked
+ assert(
+ !resultDeps.value.contains(junitPlatformLauncher)
+ )
+ }
+ }
+ }
}
}
diff --git a/libs/kotlinlib/src/mill/kotlinlib/publish/exports.scala b/libs/kotlinlib/src/mill/kotlinlib/publish/exports.scala
index c0104ef760f2..8224e34c4956 100644
--- a/libs/kotlinlib/src/mill/kotlinlib/publish/exports.scala
+++ b/libs/kotlinlib/src/mill/kotlinlib/publish/exports.scala
@@ -1,4 +1,4 @@
-package mill.kotlinlib.publish
+package mill.groovylib.publish
export mill.javalib.publish.Ivy
diff --git a/libs/kotlinlib/test/src/mill/kotlinlib/HelloKotlinTests.scala b/libs/kotlinlib/test/src/mill/kotlinlib/HelloKotlinTests.scala
index 8793905681e3..0e81d2b12114 100644
--- a/libs/kotlinlib/test/src/mill/kotlinlib/HelloKotlinTests.scala
+++ b/libs/kotlinlib/test/src/mill/kotlinlib/HelloKotlinTests.scala
@@ -40,6 +40,7 @@ object HelloKotlinTests extends TestSuite {
object main extends Cross[KotlinVersionCross](crossMatrix)
lazy val millDiscover = Discover[this.type]
+ println(millDiscover)
}
val resourcePath = os.Path(sys.env("MILL_TEST_RESOURCE_DIR")) / "hello-kotlin"
diff --git a/libs/package.mill b/libs/package.mill
index 603b5a5e7c36..98b6a978bede 100644
--- a/libs/package.mill
+++ b/libs/package.mill
@@ -10,6 +10,7 @@ import millbuild.*
object `package` extends MillStableScalaModule {
def moduleDeps = Seq(
build.libs.kotlinlib,
+ build.libs.groovylib,
build.libs.androidlib,
build.libs.scalajslib,
build.libs.scalanativelib,
diff --git a/mill-build/src/millbuild/Deps.scala b/mill-build/src/millbuild/Deps.scala
index 05936b3b95e8..f1c344f291f5 100644
--- a/mill-build/src/millbuild/Deps.scala
+++ b/mill-build/src/millbuild/Deps.scala
@@ -197,6 +197,8 @@ object Deps {
val kotlinBuildToolsApi = mvn"org.jetbrains.kotlin:kotlin-build-tools-api:$kotlinVersion"
val kotlinBuildToolsImpl = mvn"org.jetbrains.kotlin:kotlin-build-tools-impl:$kotlinVersion"
val kotlinStdlib = mvn"org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
+ val groovyVersion = "4.0.28"
+ val groovyCompiler = mvn"org.apache.groovy:groovy:$groovyVersion"
/** Used for the `mill init` from a Maven project. */
object MavenInit {
diff --git a/website/docs/modules/ROOT/nav.adoc b/website/docs/modules/ROOT/nav.adoc
index f45eb00f8263..00fe57a1a365 100644
--- a/website/docs/modules/ROOT/nav.adoc
+++ b/website/docs/modules/ROOT/nav.adoc
@@ -57,6 +57,8 @@
*** xref:javascriptlib/linting.adoc[]
*** xref:javascriptlib/publishing.adoc[]
*** xref:javascriptlib/build-examples.adoc[]
+** xref:groovylib/intro.adoc[]
+*** xref:groovylib/testing.adoc[]
* xref:comparisons/why-mill.adoc[]
** xref:comparisons/maven.adoc[]
** xref:comparisons/gradle.adoc[]
diff --git a/website/docs/modules/ROOT/pages/groovylib/intro.adoc b/website/docs/modules/ROOT/pages/groovylib/intro.adoc
new file mode 100644
index 000000000000..cab3270bd64f
--- /dev/null
+++ b/website/docs/modules/ROOT/pages/groovylib/intro.adoc
@@ -0,0 +1,40 @@
+= Building Groovy with Mill
+:page-aliases: Groovy_Intro_to_Mill.adoc
+:language: Groovy
+:language-small: groovy
+
+// START-COPY-AND-PASTE
+// Instead of this include
+// include::partial$Intro_Header.adoc[]
+// We copy the text here, as we currently have no publishing page
+
+This page contains a quick introduction to getting start with using Mill to build
+a simple {language} program. It walks through a series of Mill builds of increasing
+complexity to show you the key features and usage of the Mill build tool.
+The other pages of this section of the docs go into more depth into individual Mill features.
+These aren't intended to be read comprehensively top-to-bottom, but
+rather looked up when you have a particular interest e.g. in
+xref:{language-small}lib/testing.adoc[testing],
+xref:javalib/publishing.adoc[publishing], and so on.
+
+The API reference for Mill's {language} toolchain can be found at:
+
+* {mill-doc-url}/api/latest/mill/{language-small}lib.html[mill.{language-small}lib]
+
+If you aren't sure why you would use Mill, see xref:comparisons/why-mill.adoc[] for
+a discussion on the motivations of the project. If you are migrating an existing project,
+see xref:migrating/migrating.adoc[Migrating to Mill].
+
+// END-COPY-AND-PASTE
+
+NOTE: Mill Groovy support is currently still under active development.
+It is expected to continue evolving over time.
+
+== Simple Groovy Module
+
+include::partial$example/groovylib/basic/1-simple.adoc[]
+
+== Maven-Compatible Modules
+
+include::partial$example/groovylib/basic/2-compat-modules.adoc[]
+
diff --git a/website/docs/modules/ROOT/pages/groovylib/testing.adoc b/website/docs/modules/ROOT/pages/groovylib/testing.adoc
new file mode 100644
index 000000000000..c8577c468e96
--- /dev/null
+++ b/website/docs/modules/ROOT/pages/groovylib/testing.adoc
@@ -0,0 +1,27 @@
+= Testing Groovy Projects
+:page-aliases: Testing_Groovy_Projects.adoc
+
+
+This page will discuss common topics around working with test suites using the Mill build tool
+
+== Defining Unit Test Suites
+
+include::partial$example/groovylib/testing/1-test-suite.adoc[]
+
+== Defining Integration Test Suites
+
+include::partial$example/groovylib/testing/2-integration-suite.adoc[]
+
+== Using Spock
+
+include::partial$example/groovylib/testing/3-spock.adoc[]
+
+== Using Spock in Java projects with Maven layout
+
+include::partial$example/groovylib/testing/4-spock-for-java.adoc[]
+
+== Github Actions Test Reports
+
+If you use Github Actions for CI, you can use https://github.com/mikepenz/action-junit-report in
+your pipeline to render the generated `test-report.xml` files nicely on Github. See
+https://github.com/com-lihaoyi/mill/pull/4218/files for an example integration
\ No newline at end of file