Skip to content

Scala Support - #12695

Draft
graemerocher wants to merge 174 commits into
5.1.xfrom
core.scala
Draft

Scala Support#12695
graemerocher wants to merge 174 commits into
5.1.xfrom
core.scala

Conversation

@graemerocher

Copy link
Copy Markdown
Contributor

Summary

Adds first-class Scala 3 support to Micronaut by integrating Scala compilation with Micronaut's compile-time visitor and bean definition infrastructure.

This PR introduces the Scala compiler integration, the Scala element model used by Micronaut visitors, collection and annotation metadata support, documentation for users, and broad parity coverage for framework features exercised from Scala source.

Implementation details

Scala 3 compiler plugin integration

The implementation uses the Scala 3 compiler plugin API to observe Scala sources during compilation and feed Micronaut's compile-time processing model with Scala-native symbols, types, annotations, members, constructors, fields, methods, properties, companion structures, enum constants, and generic signatures.

The compiler plugin entry point lives under inject-scala/src/main/scala/io/micronaut/scala/processing/MicronautScalaCompilerPlugin.scala. It bridges Scala compiler phases into Micronaut processing so existing TypeElementVisitor implementations can operate over Scala classes in the same style as Java, Kotlin, and Groovy sources.

Scala element model

The inject-scala module adds a Scala-specific visitor model under io.micronaut.scala.processing.visitor, including:

  • ScalaProcessingEngine to coordinate processing.
  • ScalaVisitorContext for visitor state, generated elements, diagnostics, and classpath access.
  • ScalaClassElement, ScalaMethodElement, ScalaConstructorElement, ScalaFieldElement, ScalaParameterElement, ScalaPropertyElement, ScalaPackageElement, and enum/generic/wildcard variants.
  • ScalaAnnotationMetadataBuilder and related data types to convert Scala compiler annotation data into Micronaut annotation metadata.
  • Loaded class support for classpath symbols and inherited metadata.

This keeps the Scala implementation aligned with Micronaut's existing compile-time abstractions instead of creating a separate runtime path.

Annotation metadata and type handling

The implementation resolves Scala annotation constructor arguments, defaults, aliases, annotation mappers/remappers/transformers, class-valued members, constants, placeholders, wildcard bounds, explicit-null unions, inherited metadata, type variables, nested generics, arrays, and enum constants.

It also adds support for visitor-generated beans, visitor ordering, annotation mutations, classpath annotation lookup, service-loader expressions, package elements, member equality, and diagnostic handling at Scala compiler phase boundaries.

Bean definition, injection, AOP, and configuration support

Scala sources can now participate in core Micronaut compile-time features, including:

  • Bean discovery and bean definition generation.
  • Constructor, field, method, and member injection.
  • Qualifiers, scopes, default scopes, stereotypes, Provider, BeanRegistration, and collection/map injection.
  • Factory methods, factory properties, nullable factories, primitive factories, implicit/named factories, and factory method collision handling.
  • Bean introspection, immutable configuration properties, JavaBean-style properties, field access introspection, enum introspection, constructor forwarding, validation metadata, and property filters.
  • AOP around advice, introduction advice, lifecycle advice, retry advice, hotswap/proxy-target behavior, trait default method bridges, generated adapter methods, and generic introduction metadata.
  • Scala collection and Option handling, including mutable and immutable collection binding/injection paths.

Documentation and examples

The user guide now includes Scala language support documentation and Scala snippets across the docs where parity examples are available. The PR adds a Scala test suite with examples covering HTTP clients and servers, filters, routing, binding, uploads, SSE/streaming, configuration, factories, validation, events, propagation, resources, scopes, qualifiers, replacements, AOP, introspection, and other documented framework features.

The Scala docs examples are backed by tests in test-suite-scala, and the disabled-test tracking files document remaining parity gaps and follow-up areas.

Testing

The branch adds dedicated Scala compiler and visitor tests in inject-scala-test, plus documentation parity coverage in test-suite-scala.

Key coverage areas include:

  • Scala compiler plugin proof of concept and processing engine behavior.
  • Annotation metadata parity.
  • Bean definition and bean introspection parity.
  • Element completeness, mutation, visitor context, visitor ordering, and generated-bean coverage.
  • AOP, configuration properties, factories, qualifiers, lifecycle, collection injection, enum introspection, explicit nullability, and docs snippet parity.

Notes for reviewers

This is intentionally opened as a draft because the change set is broad and spans compiler integration, framework compile-time model support, docs, and parity tests. Review is probably easiest by starting with the Scala compiler plugin and inject-scala visitor model, then moving through the focused parity specs and finally the documentation/test-suite additions.

sdelamo and others added 30 commits May 13, 2026 11:57
* micronaut-aws = "5.0.0-RC1"

* micronaut-build-plugins="8.0.0-RC1"

* micronaut-sql = "7.0.0-RC1"

* micronaut-test = "5.0.0-RC1"

* micronaut-validation = "5.0.0-RC2"

* micronaut-rxjava3 = "4.0.0-RC1"

* micronaut-sourcegen = "2.0.0-RC4"

* micronaut-reactor = "5.0.0-RC1"

* micronaut-kotlin = "5.0.0-RC1"

* register Kotlin/KSP plugin versions once

> Changed build.gradle:1 to register Kotlin/KSP plugin versions once at the root with apply false. That removes the “Kotlin Gradle plugin was loaded multiple times” warning for the failing task.

* Updated the affected Kotest 6 API usages

- test-suite-kotlin/src/test/kotlin/io/micronaut/docs/aop/around_reactive/TxSpec.kt:18 now uses kotlinx.coroutines.runBlocking.
  - test-suite-kotlin/src/test/kotlin/io/micronaut/docs/context/events/async/SampleEventListenerSpec.kt:4 and test-suite-kotlin/src/test/kotlin/io/micronaut/docs/server/sse/HeadlineControllerSpec.kt:3 use Kotest’s new
    io.kotest.assertions.nondeterministic.eventually package and current overload.

* remove unused import

* remove deprecated constructor

> Task :micronaut-router:checkstyleMain FAILED
[ant:checkstyle] [ERROR] /Users/sdelamo/github/micronaut-projects/micronaut-core/router/src/main/java/io/micronaut/web/router/version/RouteVersionFilter.java:67: Javadoc comment at column 48 has parse error. Details: mismatched input '(' expecting JAVADOC_INLINE_TAG_END while parsing INLINE_TAG [UnusedImports]

* Using the '.*' form of import should be avoided

* remove unused constructor

* del Deprecated const in DefaultHttpClientConfiguration

* del Deprecated const in ServiceHttpClientConfiguration

* remove unused constructor

* Adapt RouteVersionFilter tests to new constructor
@graemerocher graemerocher added the type: enhancement New feature or request label Jun 1, 2026
Comment thread SCALA3_SUPPORT_PLAN.md
@@ -0,0 +1,488 @@
# Scala 3 Support for Micronaut Core

@WojciechMazur WojciechMazur Jun 5, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One things that's not in the plan is the long term maintance plan - we cannot expect Micronaut team to know Scala, and especially about compiler plugin knowledge, so if it's merged there should be somebody responsible for this part.

As a compiler team member, and release officer I can help with opening PRs for upgrade to new minor, but I have no domain knowledge about this ecosystem, so I'd rather limit my involvement to version upgrades and critical compiler fixes. However, I'm involved in multiple different OSS areas, so my time might be really limited

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hopefully this can be low overhead, but it is great if you can be a contact if we have a blocking change we can't work out ourselves

Comment thread SCALA3_SUPPORT_PLAN.md
@@ -0,0 +1,488 @@
# Scala 3 Support for Micronaut Core

@WojciechMazur WojciechMazur Jun 5, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other part is publishing and maintance of the plugin - compiler plugins have neither binary or source binary compatibility. The sources typically dont't change much, but compiler plugin is strongly coupled with compiler itself - we're not operating on abstraction over compiler, we're touching it directly, so any change compiler, but introduce source incompatibility for maintianers, or when using 2 different versions, binary issues for users.

Every compiler plugin should be published per supported Scala version using full-cross version, so we need a plan for this part as well:

  • all Scala versions since 3.3 LTS?
  • only latest versions in series LTS and Next series
  • only LTS versions?
  • a single version?

Personally I'd lean toward: latest 3.3 LTS (supported for 1 more year) and latest 3.9 LTS (first release in 6-9 weeks)

https://docs.scala-lang.org/overviews/plugins/index.html#binary-compatibility-of-compiler-plugins

Example of such an issue (an links to other known to me historical problems in the ecosystem): scalus3/scalus#225

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some good points here, it might make sense to have an external micronaut-scala project where this lives so it can have a different versioning lifecycle

Comment on lines +1641 to +1646
private def skipMethod(symbol: Symbol)(using Context): Boolean =
symbol == Symbols.NoSymbol ||
symbol.denot.isConstructor ||
hasFlag(symbol, Flags.Synthetic) ||
hasFlag(symbol, Flags.Artifact) ||
hasFlag(symbol, Flags.Accessor)

@WojciechMazur WojciechMazur Jun 5, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasFlag takes a FlagSet, yet you're invoking it multiple times

Suggested change
private def skipMethod(symbol: Symbol)(using Context): Boolean =
symbol == Symbols.NoSymbol ||
symbol.denot.isConstructor ||
hasFlag(symbol, Flags.Synthetic) ||
hasFlag(symbol, Flags.Artifact) ||
hasFlag(symbol, Flags.Accessor)
private def skipMethod(symbol: Symbol)(using Context): Boolean =
symbol.denot.isConstructor ||
hasFlag(symbol, Flags.Synthetic | Flags.Artifact | Flags.Accessor)

I think NoSymbol.denot returns NoDenotation which is safe to use directly to cehck for isConstructor of flags directly. It might only throw when trying to access owner

Comment on lines +70 to +73
override def initialize(options: List[String])(using Context): List[PluginPhase] =
val delegate = MicronautScalaCompilerPlugin.delegate(getClass)
val initializeMethod = delegate.getClass.getMethod("initialize", classOf[List[?]], classOf[Context])
initializeMethod.invoke(delegate, options, summon[Context]).asInstanceOf[List[PluginPhase]]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't quite understand why reflective invocation is required to start the compiler plugin, and why we can't do it directly. It should be documented in there.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Micronaut itself supports compiler extensions to the main processor, so I guess this is trying to allow dynamic load of these extensions, but will review if it is necessary

Comment on lines +1353 to +1379
arrayLiteralValues(tree) match
case Some(values) =>
values
case None =>
classLiteralValue(tree) match
case Some(value) =>
value
case None =>
nestedAnnotationValue(tree) match
case Some(value) =>
value
case None =>
typedConstantValue(tree) match
case Some(value) =>
value
case None =>
tree match
case literal: tpd.Literal =>
annotationConstantValue(literal.const.value).orNull
case select: tpd.Select if isEnumConstant(select.symbol) =>
select.name.toString
case ident: tpd.Ident if ident.name.toString == "_" =>
null
case ident: tpd.Ident if isEnumConstant(ident.symbol) =>
ident.name.toString
case _ =>
renderedClassLiteralValue(tree.show).map(name => classValueData(name)).getOrElse(tree.show)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using LLMs is fine for contributing, but let's keep some redability standards...
We're represeting the Scala community here against multiple JVM languages

Suggested change
arrayLiteralValues(tree) match
case Some(values) =>
values
case None =>
classLiteralValue(tree) match
case Some(value) =>
value
case None =>
nestedAnnotationValue(tree) match
case Some(value) =>
value
case None =>
typedConstantValue(tree) match
case Some(value) =>
value
case None =>
tree match
case literal: tpd.Literal =>
annotationConstantValue(literal.const.value).orNull
case select: tpd.Select if isEnumConstant(select.symbol) =>
select.name.toString
case ident: tpd.Ident if ident.name.toString == "_" =>
null
case ident: tpd.Ident if isEnumConstant(ident.symbol) =>
ident.name.toString
case _ =>
renderedClassLiteralValue(tree.show).map(name => classValueData(name)).getOrElse(tree.show)
arrayLiteralValues(tree)
.orElse(classLiteralValue(tree))
.orElse(nestedAnnotationValue(tree))
...

@gbrennon gbrennon Jun 6, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that a comment that im happy to read :)
its important to keep readability something that acts as a characteristic of that framework 👍

Comment on lines +1406 to +1407
case constant: ConstantType =>
annotationConstantValue(constant.value.value)

@WojciechMazur WojciechMazur Jun 5, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can easily drop most the the helper method

Suggested change
case constant: ConstantType =>
annotationConstantValue(constant.value.value)
case ConstantType(constant) =>
constant.value match
case _: Unit => None
case value: AnyVal => Some(value)
case value: String => ???
case _ => null

Comment on lines +755 to +765
literal.const.value match
case value: String => value
case value: java.lang.Boolean => value
case value: java.lang.Byte => value
case value: java.lang.Short => value
case value: java.lang.Integer => value
case value: java.lang.Long => value
case value: java.lang.Float => value
case value: java.lang.Double => value
case value: java.lang.Character => value
case _ => null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't that be easier to flip the check? Literal can be AnyVal | Class | String | Nul, AnyVal includes Unit
Constant has isAnyVa check so only remaining check guarding against Unit.

Comment on lines +284 to +294
private val ScalaPrimitiveNames = Map(
"scala.Boolean" -> "boolean",
"scala.Byte" -> "byte",
"scala.Char" -> "char",
"scala.Double" -> "double",
"scala.Float" -> "float",
"scala.Int" -> "int",
"scala.Long" -> "long",
"scala.Short" -> "short",
"scala.Unit" -> "void"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generaly you should match on symbol or type, not the string representation

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants