Data model¶
The data model is the set of typed values that sit between the parsers and the KotlinPoet generators. Parsers never hand KSP types to a generator. Each parser produces
one of these values, and the generators read nothing else. They live in codegen/processor/src/main/kotlin/io/github/thomaskioko/codegen/processor/data/.
There are five top level types:
- The presenter annotation
@NavDestinationresolves to aNavData. - The UI renderer annotations (
@ScreenUi,@SheetUi,@TabUi) resolve to aUiBindingData. - The application root presenter annotation (
@AppRoot) resolves to anAppRootData. - The application root composable annotation (
@AppRootUi) resolves to anAppRootUiData. - The annotation for a child presenter owned by a parent presenter (
@ChildPresenter) resolves to aChildPresenterData.
NavData and ChildPresenterData share one supertype, GraphData. It holds the names ScreenGraphGenerator needs to emit a graph extension, so screens, tab
roots, and child presenters all go through that one generator. UiBindingData, AppRootData, and AppRootUiData share no supertype, because the generators that
consume them produce structurally different output.
GraphData¶
GraphData is a plain interface in data/NavData.kt. NavData extends it, and ChildPresenterData implements it directly.
internal interface GraphData {
val parentScope: ClassName
val scope: ClassName
val graphClassName: ClassName
val graphFactoryFunName: String
val graphPropertyType: ClassName
val graphPropertyName: String
}
NavData adds the names the binding generators need on top. ChildPresenterData adds nothing, because a child presenter has no binding.
NavData¶
NavData is a sealed interface with two implementations: ScreenData for stack screens and modal overlays, and TabData for top level tab roots.
internal sealed interface NavData : GraphData {
val presenterClass: ClassName
val baseName: String
val packageName: String
val bindingClassName: ClassName
val graphFactoryClassName: ClassName
get() = graphClassName.nestedClass("Factory")
}
ScreenData carries the route class, an optional @AssistedFactory ClassName, the route property name (when the presenter takes a runtime parameter), and a
ScreenKind enum (SCREEN or OVERLAY). The binding generator uses the enum to pick NavDestination.Screen or NavDestination.Overlay. The factory field marks
a parameterized presenter. When factory is null, the presenter uses plain @Inject and the generated graph exposes the presenter directly. When factory is non null,
the presenter uses @AssistedInject and the generated graph exposes the factory. The derived isParameterized flag reads factory != null.
TabData carries the route plus a configEnclosing field for nested route classes. It has no factory branch, because tabs always use plain @Inject. The parser rejects
@AssistedInject tab presenters explicitly; see parsers.md.
graphPropertyName and graphPropertyType are the one place where screens and tabs produce different output further down. For a parameterized presenter, ScreenData
resolves them to the factory's name and class. Otherwise it resolves them to the presenter's name and class. TabData always resolves to the
presenter.
Naming on the data class¶
We compute the derived properties (graphClassName, bindingClassName, graphPropertyName, graphFactoryFunName) on the data class, not in the generators.
There are two reasons:
- They are pure functions of
baseNameandpackageName. Computing them once at parse time keeps naming logic out of the generators. - The naming convention is the contract between this processor and the consumer project. Goldens pin the names, and tests fail when they drift. With the convention on the data class, "what is this file called" is a single grep.
UiBindingData¶
UiBindingData is one data class plus a UiBindingKind enum (Screen, Sheet, or Tab).
internal data class UiBindingData(
val kind: UiBindingKind,
val composableFunction: MemberName,
val presenterClass: ClassName,
val packageName: String,
val parentScope: ClassName,
)
composableFunction is a KotlinPoet MemberName, not a ClassName. The generated code calls the composable as a top level function, and MemberName is
what KotlinPoet's %M interpolation expects. Building it once here saves the generator from deriving it again.
UiBindingGenerator switches on kind to pick three things. The content type is ScreenContent for Screen and Tab, and SheetContent for Sheet. The destination
cast target is ScreenDestination<*> for Screen, SheetDestination<*> for Sheet, and TabChild<*> for Tab. The composable gets a Modifier parameter
for Screen and Tab, but not for Sheet. The switch lives inside the generator as a private Variant data class; see generators.md.
AppRootData¶
AppRootData is one data class, produced by parseAppRootData.
internal data class AppRootData(
val implClassName: ClassName,
val interfaceClassName: ClassName,
val factoryClassName: ClassName,
val factoryFunctionName: String,
val parentScope: ClassName,
val packageName: String,
)
The generator emits a @BindingContainer @ContributesTo(parentScope) object <InterfaceName>BindingContainer. It holds one @Provides @SingleIn(parentScope) function
that takes a ComponentContext and the nested factory, and returns the bound interface. Every name the generator needs (the binding object name, the provide function
name) is derived on the data class:
val bindingClassName: ClassName =
ClassName(packageName, "${interfaceClassName.simpleName}BindingContainer")
val provideFunName: String =
"provide${interfaceClassName.simpleName}"
We capture the factory's function name (factoryFunctionName) at parse time instead of hardcoding it. That way a non standard factory name (build, make, etc.) works
without touching the generator.
AppRootUiData¶
AppRootUiData is one data class, plus an AppRootUiParameter data class for each parameter on the annotated composable other than the modifier.
internal data class AppRootUiData(
val composableFunction: MemberName,
val packageName: String,
val parameters: List<AppRootUiParameter>,
val hasModifier: Boolean,
val parentScope: ClassName,
)
internal data class AppRootUiParameter(
val name: String,
val type: TypeName,
)
composableFunction is a MemberName for the same reason as on UiBindingData: the generated code calls the composable as a top level function through KotlinPoet's
%M interpolation. parameters holds the composable's parameters other than the modifier, in declaration order. The generator turns each entry into a val on the
generated AppRootProvider interface, and uses the same name when it calls the composable inside the generated extension. hasModifier records whether the composable
takes a modifier: Modifier parameter, so the generator knows whether to forward the receiver's modifier.
ChildPresenterData¶
ChildPresenterData is one data class, produced by parseChildPresenterData.
internal data class ChildPresenterData(
val presenterClass: ClassName,
val baseName: String,
val packageName: String,
override val scope: ClassName,
override val parentScope: ClassName,
val factory: ClassName? = null,
) : GraphData
The generator emits a @GraphExtension(scope) interface <BaseName>ChildGraph that exposes the presenter as a property. The factory field works as it does on
ScreenData. When it is null, the presenter uses plain @Inject and the graph exposes the presenter. When it is set, the presenter uses @AssistedInject and the
graph exposes its nested @AssistedFactory instead, so the parent can pass runtime values to create(...). Inside it is a nested
@ContributesTo(parentScope) @GraphExtension.Factory interface whose single function returns the graph. The derived properties on the data class fix the names:
val graphClassName: ClassName = ClassName(packageName, "${baseName}ChildGraph")
val graphFactoryFunName: String = "create${baseName}Graph"
val graphPropertyType: ClassName = factory ?: presenterClass
val graphPropertyName: String =
if (factory != null) {
baseName.replaceFirstChar { it.lowercaseChar() } + "Factory"
} else {
baseName.replaceFirstChar { it.lowercaseChar() } + "Presenter"
}
The factory function name includes baseName (instead of a fixed createGraph) so two child graphs in the same parent scope do not collide. Metro merges every
@GraphExtension.Factory interface contributed to a scope into the parent graph. If two factory functions had the same name, you would get an
Incompatible return types compile error at the activity graph.
Intermediate values¶
The generators produce KotlinPoet FileSpec outputs. KSP types like KSClassDeclaration carry resolution state and lazy children, and they only live for one round.
We translate once, at the parser boundary, which gives us three things:
- Generators are pure functions of the intermediate value. They are easy to test, easy to read, and easy to golden.
- KSP specific logic (reading annotation arguments, walking nested declarations) stays in the parsers.
- The intermediate value is the contract between the two halves of the processor. When you wire a new annotation through the pipeline, start with the file
under
data/.