plugins/compose/design/target-inference.md
Composition produces a tree of nodes and uses an applier to build and make changes to the tree
implied by composition. If a composable function directly or indirectly calls ComposeNode then it
is tied to an applier that can handle the type of node being emitted by the
ComposeNode. ComposeNode contains a runtime check to ensure that the applier is of the expected
type. However, currently, there is no warning or error generated when a composition function is
called that expects a different applier than is provided.
For Compose UI 1.0 this was not as important as there are only two appliers used by Compose UI, the
UIApplier and the VectorApplier and vector composable are rarely used so calling the incorrect
composable function is rare. However, as appliers proliferate, such as Wear projections, or menu
trees for Compose Desktop, the likelihood of calling an incorrect composable grows larger and
detecting these statically is more important.
Requiring to specify the applier seems clumsy and inappropriate since, in 1.0, it wasn't necessary,
and especially since the inference rules are fairly simple. There is only one parameter that needs
to be determined, the type of the applier of the implied $composer parameter. From now on I will
be referring to this as the type of the applier when it technically is the type of the applier
instance used by the composer. In most cases the applier type is simply the applier type required
by composition functions it calls. For example, if a composable function calls Text it must be a
UiApplier composable because Text requires a UIApplier.
The following Prolog program demonstrates how type type of the applier can be inferred from the content of the function (https://swish.swi-prolog.org/p/Composer%20Inference.swinb):
% An empty list can have any applier.
applier([], _).
% A list elements must have identical appliers.
applier([H|T], A) :- applier(H, A), applier(T, A).
% A layout has a uiApplier
applier(layout, uiApplier).
% A layout with content has a uiApplier and its content must have a uiApplier.
applier(layout(C), uiApplier) :- applier(C, uiApplier).
% A vector has a vector Applier.
applier(vector, vectorApplier).
% A vector with content has a vector applier and its content must have a vector applier
applier(vector(C), vectorApplier) :- applier(C, vectorApplier).
The above corresponds to calling ComposeNode (from Layout.kt and Vector.kt) and can easily be
derived from the body of the call. Taking advantage of of Prolog's unification algorithm, this can
also express open composition functions like the CompositionLocalProvider,
% provider provides information to its content for all appliers.
applier(provider(C), A) :- applier(C, A).
This predicate binds A to whatever applier the content C requires. In other words, A is an
open applier bound by the lambda passed into provider.
The above allows the validation that the composition function represented by, for example,
program(
row([
drawing([
provider([
circle,
square
])
])
])
).
will not generate an applier runtime error (demonstrated in the link above).
Inferring the applier type is translated into inferring one of two attributes for every composable
function or composable lambda type, ComposableTarget and ComposableOpenTarget.
@Retention(AnnotationRetention.BINARY)
@Target(
AnnotationTarget.FUNCTION,
AnnotationTarget.PROPERTY_GETTER,
AnnotationTarget.TYPE,
AnnotationTarget.TYPE_PARAMETER,
)
annotation class ComposableTarget(val applier: String)
@Retention(AnnotationRetention.BINARY)
@Target(
AnnotationTarget.FUNCTION,
AnnotationTarget.PROPERTY_GETTER,
AnnotationTarget.TYPE,
AnnotationTarget.TYPE_PARAMETER,
)
annotation class ComposableOpenTarget(val index: Int)
Every composable function has an applier scheme determined by the composable functions it calls or
by explicit declarations. The scheme for a function is the applier token or open token variable for
each @Composable function type in the description including the description itself and
corresponds to the applier required by the respective composable function.
For the purposes of this document, the scheme is represented by a Prolog-like list with identifier
symbols being bound and deBruijn indices (written
as a backslash followed by the index such as \1) are unbound variables and unbound variables with
the same index bind together. A scheme is a list where the first element is the variable or token
for the $composer of the function followed by the schemes for each composable parameter (which
might also contain schemes for its parameters).
For example, a composable function declared as,
@Composable
@ComposableTarget("UI")
fun Text(value: String) { … }
has the scheme [UI].
Open appliers, such as,
@Composable
@ComposableOpenTarget(0)
fun Providers(
providers: vararg values: ProvidedValue<*>,
content: @ComposableOpenTarget(0) @Composable () -> Unit
) {
…
content()
…
}
Has the scheme [\0, [\0]] meaning the applier for Providers and the content lambda must be
the same but they can be any applier. Using these schemes an algorithm similar to the Prolog
algorithm can be implemented. That is, when type variables are bound, the variables are unified
using a normal unify algorithm. As backtracking is not needed so a simple mutating unify algorithm
can be used as once unification fails the failure is reported, the requested binding is ignored,
and the algorithm continues without backtracking.
When inferring an applier scheme,
For each function or type with an inferred scheme the declaration is augmented with attributes
where CompositionTarget is produced for tokens and CompositionOpenTarget is produced with the
deBruijn index. This records the information necessary to infer appliers across module boundaries.
If any of the bindings fails (that is if it tries to unify to two different tokens) a diagnostic is produced and the binding is ignored. Each variable can contain the location (e.g. PsiElement) that it was created for. When the binding fails, the locations associated with each variable in the binding group can be reported as being in error.
ComposableInferredTargetDue to a limitation in how annotations are currently handled in the Kotlin compiler, a plugin
cannot add annotation to types, specifically the lambda types in a composable function. To work
around this the plugin will infer a ComposableInferredTarget on the composable function which
contains the scheme, as described above, instead of adding annotations to the composable lambda
types in the function. For the target type checking described here ComposableInferredTarget
takes presedence over any other attriutes present on the function. The implementation dropped
the leading \ from the deBruijn indexes, to save space, as they were unnecessary.
Because backtracking is not required, the unify algorithm implemented takes advantage of this
by using a circular-linked-list to track the binding variables (two circular-linked lists can
be concatenated together in O(1) time by swapping next pointer of just one element in each).
The bindings can be reversed (e.g. swapping the next pointers back), but the code is not
included to do so, so if backtracking is later required, Bindings would need to be updated
accordingly. Details are provided in the class.