Macro-Paradise for Scala 3 is an experimental compiler-plugin project inspired by Scala 2 Macro Paradise. It explores pre-typer annotation expansion so that generated class members, companions, and sibling definitions are available to ordinary Scala typing in the same compilation run.
The core mechanism and a precompiled external-handler path are executable and
well tested. The project is still compiler-sensitive research: its API,
configuration, supported shapes, and compatibility policy may change. The
immutable 0.1.1 release supports exact Scala 3.3.8, 3.8.4, and stable
3.9.0 and is available from Maven Central. Current main continues on the
experimental 0.2.0-SNAPSHOT development line.
The fastest verified released setup is the public
macroparadise-scala3.g8
template:
sbt new DmytroMitin/macroparadise-scala3.g8
cd <generated-project>
sbt "core/run"Expected output:
Hello, Greeter!
The generated build requires JDK feature 25 and sbt 1.12.15. It defaults to
exact Scala 3.9.0, with exact 3.3.8 and 3.8.4 also selectable, and resolves the
immutable Macro-Paradise 0.1.1 release. Its macro-annotations,
macro-handlers, and core projects demonstrate a user-owned marker,
precompiled external handler, and ordinary consumer. Macro-Paradise remains
experimental and exact-compiler-specific.
A user can define an annotation marker, its precompiled handler, and an annotated consumer. The marker carries only the handler identity:
package com.example.`macro`.annotations
import paradise3.api.expander
import scala.annotation.StaticAnnotation
@expander("com.example.macro.handlers.IdentityHandler")
final class identity extends StaticAnnotationThe first handler is intentionally identity/pass-through. It reads a
plugin-minted ExpansionInput; user code does not construct or copy that value:
package com.example.`macro`.handlers
import dotty.tools.dotc.core.Contexts.Context
import paradise3.api.*
final class IdentityHandler extends ExpansionHandler:
override def annotationName: String =
"com.example.macro.annotations.identity"
override def expand(input: ExpansionInput)(using Context): ExpansionOutcome =
ExpansionEdit.finish(ExpansionEdit.start(input))The consumer imports the marker and uses short @identity as the normal form:
package com.example.core
import com.example.`macro`.annotations.identity
@identity
class SomethingThis behavior-free first use proves discovery, binding, loading, invocation, and unchanged pass-through. The complete marker/handler/consumer tutorial and both manual sbt translations are in External handler authoring.
The next step is source-like member generation. The canonical example uses
Scalameta q syntax, Quasiquotes 0.3.0 generated-origin lowering, and the generic
MacroParadise placement helper. The bridge supplies honest source/span
provenance; no raw compiler constructor appears in this normal path:
package starter.handler
import dotty.tools.dotc.core.Contexts.Context
import paradise3.api.*
import paradise3.api.helpers.ExpansionHelpers
import quasiquotes.definitions.dotty.ScalametaDefinitionGeneratedOriginBridge
import scala.meta.*
import scala.meta.dialects.Scala3
final class GenerateGreetingHandler extends ExpansionHandler:
val annotationName: String = "starter.marker.generateGreeting"
def expand(input: ExpansionInput)(using Context): ExpansionOutcome =
ExpansionEdit.finish:
for
edit <- ExpansionEdit.start(input)
_ <- input.primary match
case ExpansionTarget.Class(_) => Right(())
case _ => Left(ExpansionDiagnostic("@generateGreeting requires a class primary", input.currentAnnotation.sourcePos))
definition = q"""def generatedGreeting: String = "Hello, Greeter!" """.asInstanceOf[Defn.Def]
lowered <- ScalametaDefinitionGeneratedOriginBridge
.lower(
definition,
"<macroparadise-generated:GenerateGreetingHandler:generatedGreeting>"
)
.left
.map(error => ExpansionDiagnostic(s"${error.code}: ${error.detail}", input.currentAnnotation.sourcePos))
result <- ExpansionHelpers.placeMemberInPrimary(edit, lowered.tree)
yield resultThe handler project declares
("com.github.dmytromitin" % "quasiquotes-scala3-dotty-internal" % "0.3.0").cross(CrossVersion.full).
Its marker points to GenerateGreetingHandler; after both are compiled, an
ordinary consumer can use new Greeter().generatedGreeting. The repository
mechanically compiles and runs this exact source in the independent external
handler starter. Direct untpd construction remains an expert escape hatch,
not the primary authoring path.
This fixture is intentionally narrow. It is evidence for the compiler mechanism, not a general-purpose macro-annotation API.
The source build requires:
- JDK feature version 25;
- sbt 1.12.15;
- Scala
3.3.8,3.8.4, or stable3.9.0, selected as a separate exact build lane.
The build rejects other JDK feature versions before normal tasks run. The plugin and handler contract expose Scala compiler internals, so a nearby Scala version is not an interchangeable substitute.
The JDK contract is feature version 25, not one vendor or 25.0.x patch. The
root .java-version contains 25 as a convenient local version-manager hint;
the build-time verifier remains authoritative. See
Getting started
for concise jenv and SDKMAN setup guidance.
IntelliJ must launch sbt itself on JDK 25; setting only the source language
level or Scala SDK is insufficient. For this project, open
Settings | Build, Execution, Deployment | Build Tools | sbt, set
JVM | JRE to a JDK 25 installation, and also set the project SDK to JDK 25
under File | Project Structure | Project.
If an import command starts with a Java 8 executable such as
.../corretto-1.8.../bin/java, IntelliJ has selected the wrong sbt JVM.
Changing the Scala version is not the fix. Select JDK 25 in both locations,
then refresh or reimport the sbt project. An unsupported importer JVM is
rejected during meta-build settings load, before the ordinary project/*.scala
helpers compile.
For packaged external-handler projects, delegate build and run actions to sbt.
The sbt-imported workflow is qualified; IntelliJ's native JPS compiler path is
not currently part of the supported boundary. The .java-version hint does
not automatically configure IntelliJ's project SDK or sbt JVM.
Run the complete product gate from the repository root:
sbt -batch verifyPublicProductBoundaryThe gate runs nonempty plugin and consumer suites, packages the plugin and
experimental handler contract, checks the normalized API surface, exercises
independent consumers, verifies the external-handler starter, and confirms
that only the compiler plugin and handler API are top-level product artifacts
for publishLocal. The separate source-built sbt module also validates its
local/test packaging while every remote publication path remains fail closed.
For an additional product-only isolation proof, run the same canonical gate in a disposable source copy with fresh dependency and build caches:
scripts/verify-public-product-fresh-copy.shThis companion gate copies only tracked and task-owned untracked product files; it does not read a controller checkout or reuse repository build output.
For a smaller ordinary development pass:
sbt -batch testThe immutable Central release and current source checkout are separate states.
Released 0.1.1 is available for each supported exact Scala line:
ThisBuild / scalaVersion := "3.3.8" // or exact 3.8.4 / 3.9.0
addCompilerPlugin(("com.github.dmytromitin" % "macroparadise-scala3-plugin" % "0.1.1").cross(CrossVersion.full))To install current 0.2.0-SNAPSHOT from this checkout for one exact line,
select that line explicitly and publish only to the machine-local repository:
sbt -Dmacroparadise.exactScalaVersion=3.3.8 -batch "++3.3.8!" "pluginApi/publishLocal" "plugin/publishLocal"
sbt -Dmacroparadise.exactScalaVersion=3.8.4 -batch "++3.8.4!" "pluginApi/publishLocal" "plugin/publishLocal"
sbt -Dmacroparadise.exactScalaVersion=3.9.0 -batch "++3.9.0!" "pluginApi/publishLocal" "plugin/publishLocal"Then a local development consumer uses the matching exact line and snapshot:
ThisBuild / scalaVersion := "3.3.8" // or exact 3.8.4 / 3.9.0
addCompilerPlugin(("com.github.dmytromitin" % "macroparadise-scala3-plugin" % "0.2.0-SNAPSHOT").cross(CrossVersion.full))CrossVersion.full is required; %% produces only a binary Scala suffix and
does not name this exact-compiler plugin. Release 0.1.1 is published for all
three exact lines. The 0.2.0-SNAPSHOT coordinate is source-build/local-
publication support only and is not published remotely.
The plugin JAR is self-contained for compiler loading: it embeds the exact
unshaded paradise3.api runtime classes that the plugin links against. Its POM
does not add a second runtime API dependency. An ordinary API dependency is a
source-compilation dependency for marker/handler authors; it is not, and need
not be, part of -Xplugin.
Authors of precompiled annotation markers and handlers also use the exact
full-cross macroparadise-scala3-plugin-api coordinate described in
External handler authoring. Ordinary
plugin-only users do not add implementation or repository test artifacts.
The supported external-handler flow has three precompiled stages: marker, handler, then annotated consumer. Choose one of two top-level setups:
- sbt integration (recommended normal path): use same-build local marker
and handler projects with no producer
publishLocal, or use genuinely published marker/handler modules. Marker-only local projects normally useprovided->compile, and marker-only published modules normally use% Provided, keeping marker API available for compilation but absent at runtime; - fully manual: keep the ordinary marker dependency and wire the compiler plugin, complete handler expansion classpath, and content identity directly.
The sbt-integration module documents both plugin
producer modes. Its 0.1.1 release is available remotely for normal use:
addSbtPlugin("com.github.dmytromitin" % "sbt-macroparadise" % "0.1.1")To test current 0.2.0-SNAPSHOT source instead, first run sbt -batch publishLocal inside sbt-integration/ and select that local snapshot in
project/plugins.sbt. The static local-project helper deliberately does not
infer the marker dependency; the consumer must still declare
.dependsOn(macroAnnotations % "provided->compile") for marker-only API, or a
plain dependency for intentionally runtime-bearing marker API. Current source
also provides a Seq[ProjectReference] overload for multiple local marker and
handler projects. The complete manual escape hatch is in
External handler authoring.
The plugin loader sees the self-contained
plugin JAR; the ordinary source classpath sees the API and precompiled marker;
the handler is selected through
-P:macroparadise:handlerClasspath=<handler-jar>. A second build-only option
contains a SHA-256 identity derived from every explicit marker artifact and the
complete ordered effective handler expansion classpath, including handler
dependencies, so Zinc invalidates consumers when any role input changes at a
stable path. The running
application does not need the compiler plugin or handler JAR merely because
they were used at compile time. Same-compilation marker metadata is not
currently claimed.
Start with the minimal user-defined @identity example. Its handler returns the
annotated class unchanged, so successful compilation isolates marker discovery,
metadata binding, imported-short canonicalization, handler loading, and one
invocation from generated-member behavior. The independent external sbt proof
also compiles the direct-qualified control and checks the exact marker/handler/
consumer dependency graph.
sbt -batch verifyIndependentExternalSbtConsumerFromLocalRepositoryThen use the fixture-independent generateGreeting starter to prove generated-
member typing and runtime behavior on the same three-role topology. Its
packaged precheck retains the maximum-witness explicit form and a bounded compact
form without changing the experimental handler API.
sbt -batch verifyExternalHandlerAuthoringStarterThe copy/paste identity tutorial, including a no-AutoPlugin published-module
recipe, is in
External handler authoring; the executable
starter example is the generated-
output follow-on. The exact hyphenated-directory fixture for same-build manual,
same-build local-project, AutoPlugin published-module, and manual
published-module setup is retained under
examples/user-onboarding-three-mode-fixture.
The current implementation provides bounded evidence for:
- top-level class rewriting before typer;
- generated class methods;
- companion creation and existing-companion merge;
- generated sibling classes;
- structured primary, companion, and ordered additional-output roles;
- a released experimental syntactic pre-typer read-only view of ordered direct members and bounded direct-method structure, with a deliberately tiny type-shape normalization;
- raw untyped output as an expert escape hatch;
- precompiled external handlers selected explicitly or by marker metadata;
- qualified syntactic annotation identities and unambiguous package-level explicit-import canonicalization;
- plugin-owned, source-ordered composition with applicability decided inside
each handler's
expandmethod; - a restricted generic-trait contextual companion-method fixture.
Legacy source-annotation composition is fail closed. Every participant must
explicitly accept the concrete target inside expand, preserve remaining
handled annotations exactly, and satisfy the plugin's output and rollback
invariants. The coordinator is generic, but positive evidence remains bounded
to the combinations in the test suite.
- External handlers and their raw tree values are tied to the exact compiler build.
- Annotation matching is syntactic. One unambiguous, source-preceding, package-level explicit import is supported; alias, wildcard, local/nested, given, export, symbol, and general semantic resolution are not implemented.
- General same-module handler support is deferred. Current
maincontains a separate opt-in experimental implementation of one bounded different-file Model A: one explicit marker source, one explicit handler source, exact source-byte identity, compiler-unit suspension, and fresh current-output handler loading. Exact Scala 3.3.8, 3.8.4, and 3.9.0 CLI/Zinc qualification passes. Persistent sbt BSP and live IntelliJ qualification remain bounded to exact Scala 3.3.8 and 3.8.4 when the sbt-imported project delegates Build and Run to sbt on JDK 25 and sbt 1.12.15, including handler-only edits withoutcleanand a fresh session after close/reopen. Same-file marker/handler/consumer topologies, dependency cycles, automatic discovery, multiple configured relationships, and native IntelliJ/JPS compilation remain rejected, unimplemented, or unqualified, so general same-module support remains false. Precompiled handlers remain the broad/default supported experimental path. - The project does not provide arbitrary target shapes, arbitrary definition
construction, arbitrary composition, typed tree construction, semantic
member analysis, or a stable public API. The bounded body and type-structure
views perform no typing, symbol/owner lookup, inheritance, alias expansion,
subtyping, or overload resolution. The additive type-structure view makes
absent, present-supported, and present-unsupported source bounds distinct,
and separates abstract bounded direct type members from aliases,
polymorphic, modifier-bearing, and unsupported forms. Its shared simple
named-type case records only an unqualified syntactic name such as
StringorNat; applied, qualified, refined, function, and broader shapes remain unsupported. RawExpansionInput.primary.treeremains the exact-compiler escape hatch. - Quasiquotes integration is optional cross-project research, not a product build dependency.
- Top-level local publication is enabled only for the exact-cross plugin and
handler API; the sbt module has separate packaging and a published
0.1.1coordinate. Released0.1.1includes the bounded direct-body and type- structure views but has no implied release cadence or production support commitment. - Released
0.1.1has no public object-target routing. Current0.2.0-SNAPSHOTsupports one ordinary top-level object through the role-aware handler API, with immutable helper composition inside one handler. General public U-style existing-definition transformation authoring is not a released API.
See Supported scope and limitations for the detailed boundary.
- Quasiquotes for Scala 3 explores a compiler-neutral Scalameta authoring model and exact-compiler lowering adapters. It is independent, optional research rather than a Macro-Paradise product dependency.
- AUXify for Scala 3 is an
independent downstream consumer. Its current source-built development slices
include bounded
@apply,@aux,@instance,@delegated, and default@selfhandlers;@syntaxis characterized but not implemented. See the real downstream examples for exact status and ownership boundaries.
- Getting started
- Architecture
- Quasiquote and pre-typer AST architecture
- Supported scope and limitations
- External handler authoring
- Diagnostics and troubleshooting
- Compatibility
- Versioning and stability
- Roadmap
Before proposing a change, read Contributing. For usage questions and defect reports, see Support. Security-sensitive material must not be posted publicly; the current reporting limitation is explained in the Security policy.
The source is licensed under the Apache License 2.0. The plugin and
handler-facing API remain experimental, compiler-version-specific, and without
stability guarantees. The plugin, plugin API, and sbt integration are published
as 0.1.1; compiler-facing artifacts use exact full crossing for Scala 3.3.8,
3.8.4, and 3.9.0. Current 0.2.0-SNAPSHOT development is not published
remotely. Internal fixtures, tests, examples, consumers, and spikes remain
unpublished.
See Expansion model and composition for mandatory primary roles, optional opposites, lease omission, and atomic helper programs.