Skip to content

Contributing to Metro

Metro welcomes contributions! Small contributions like documentation improvements, small obvious fixes, etc are always good and don’t need prior discussion. I liberally leave TODO comments in code that don’t quite meet the standard of an issue but are still things worth improving :). For larger functionality changes or features, please raise a discussion or issue first before starting work.

Development

Local development with Metro is fairly straightforward. You should be able to clone the repo and open it in IntelliJ as a standard Gradle project.

There are a few primary subprojects to consider.

  1. :compiler — Metro’s compiler plugin implementation lives. This includes compiler-supported interop features too.
  2. :runtime — Metro’s core multiplatform runtime API. This is mostly annotations plus some small runtime APIs.
  3. :integration-tests — self-explanatory.
  4. :interop-dagger — An ancillary set of JVM-only Dagger-specific runtime APIs for interop with Dagger.
  5. :gradle-plugin — Metro’s companion Gradle plugin implementation. Mostly just an extension API and compiler plugin wiring with KGP.
  6. samples/ — A separate gradle project that contains several sample projects. This includes the core artifacts as an included build. You can add this project in IntelliJ as another Gradle project to support developing both.
  7. :compiler-tests — Compiler tests using JetBrains’ official compiler testing infrastructure.

There is a useful ./metrow helper CLI that can perform a few common commands across the various subprojects. Before submitting a PR, it is useful to run the following:

  1. ./metrow regen — This regenerates .api files and runs all code formatters.
  2. ./metrow check — This runs checks across all included Gradle projects (including samples and the Gradle plugin).

Testing

Tests are spread across a few areas.

  • compiler-tests/ — New compiler tests using JetBrains’ official compiler testing infrastructure. If possible, write new compiler tests in here! See this PR for more details on how they work.
  • compiler/src/test/ — Core compiler tests. These should be focused primarily on error testing but can also perform limited functional testing. Note that while many tests are here, new tests should ideally use compiler-tests.
  • integration-tests/ — Integration tests. These should only be functional in nature and not test error cases (error cases won’t compile!). Note that new integration tests should usually be written in compiler-tests. Some scenarios, such as multi-compilation tests across Gradle, may make more sense to write here.
  • samples/ — Some samples have tests! This is useful to assert that these samples work as expected.

Compiler Plugin Design

The compiler plugin is implemented primarily in two parts.

1. FIR

The FIR frontend generates declarations, generates supertypes, and performs diagnostic checks for Metro types. Any class or callable declaration generated by Metro should be done here as this is required for them to be visible in Kotlin metadata later.

Generators go in the dev.zacsweers.metro.compiler.fir.generators package.

Checkers go in the dev.zacsweers.metro.compiler.fir.checkers package.

New checker contributions are generally welcome. New generators almost always warrant prior discussion first!

2. IR

The IR backend performs two main functions:

  1. Implements declarations generated in FIR. This includes generated graphs, factories, member injectors, etc.
  2. Performs dependency graph construction and validation. This is primarily spread across DependencyGraphTransformer, BindingGraph, and Binding.

Most of this is implemented as transformers in the dev.zacsweers.metro.compiler.ir.transformers package. Note that all transformers are run from the DependencyGraphTransformer, which is the only true IrTransformer of the bunch and just delegates out to the other transformers as needed.

Aggregation hint properties are also implemented in IR as a workaround to support incremental compilation. See ContributionHintIrTransformer for more details.

TypeKey and ContextualTypeKey

TypeKey and ContextualTypeKey (and their FIR counterparts) deserve special mention. Most of the compiler’s dependency graph analysis thinks in terms of these two types.

A TypeKey is the canonical representations of specific binding, composed of a type and optional qualifier.

A ContextualTypeKey can be thought up as a TypeKey with context of how it’s used. This is useful for a few reasons:

  • Allows Metro’s compiler plugin to generate code accordingly for how the given TypeKey is used at runtime, for example wrapping in Provider, Lazy, etc.
  • Allows dependency graph resolution to understand if the type is deferrable, which is useful in breaking dependency cycles.

Misc Notes

  • IR code should cache eagerly.
  • FIR code should cache carefully (remember it runs in the IDE!).
  • FIR code should be defensive. It may run continuously in the IDE and not all information may be available to the compiler as the user has written it. If you’ve ever written a custom lint check, your methodology should be similar.
  • Inversely, IR code should be offensive. Assert expectations with clear error messages, report errors with useful error messages.
  • FIR-generated declaration should use descriptive keys to declarations that can be referenced later in FIR and IR (as origins). See Keys.kt for FIR declarations and Origins.kt for their IR analogs.