Enhancements and processors
Versatia does not hardcode what any annotation means. Every annotation the framework acts on is an
enhancement, and the behaviour behind it lives in an enhancement processor. The bean scopes
and @AutoInvoke are built this way, with nothing special about them.
Enhancements are the framework's only extension mechanism: a feature is a set of definitions that the compile-time step records in the descriptor plus a processor that interprets them. The runtime itself stays simple: read the descriptor, load the processors it names, hand each one its slice, let the processor do the work. See Architecture for the rules this implies.
Vocabulary
- Enhancement: an annotation whose occurrences the framework acts on. It names its processor
class and the phase it runs at with
@VersatiaEnhancement(dev.versatia). - Element: one recorded occurrence in the descriptor, with the processor class, the phase, the annotation as written and the target (a class, a function, a property or the plugin's main class).
- Processor: the class, loaded by name, that receives the elements naming it and does something with them. Processors live in VersatiaCore, or in any plugin the enhanced plugin depends on.
- Attribute: an annotation marked
@VersatiaAttribute. Wherever it appears on an enhanced class, its constructor parameters, an enhanced function or its parameters, or an enhanced property, the descriptor records it with its arguments, so processors can read it.@Qualifierand@Primaryare attributes. - Annotation inheritance: an annotation class annotated with another annotation inherits it,
transitively. When
@LocalBeanis annotated with@Beanand@VersatiaEnhancement, every class marked@LocalBeancounts as marked with both; a@Serviceannotated with@LocalBeaninherits them too.
@VersatiaEnhancement
@Target(AnnotationTarget.ANNOTATION_CLASS)
@Retention(AnnotationRetention.BINARY)
@Repeatable
annotation class VersatiaEnhancement(
val processorClass: String,
val enhancementPhase: Phase = Phase.UNSET,
val enhancementPhaseParamRefName: String = "",
)
processorClassis the fully qualified name of the processor. It is a name rather than a class because the plugin compiles against VersatiaAPI only; the processor is in VersatiaCore or in another plugin. The names of the built-in processors are kept inBeanProcessors,LifecycleProcessors,ConfigProcessors,ResourceProcessors,EventProcessorsandTaskProcessors. A mistyped name is reported when the plugin enables.- Exactly one of
enhancementPhase(fixed) andenhancementPhaseParamRefName(read from a property of the annotated annotation, per occurrence) must be set; see Phases and events. - It is repeatable: several processors can act on one annotation, each producing its own element.
The built-in enhancements:
@Bean
@VersatiaEnhancement(BeanProcessors.LOCAL, enhancementPhase = Phase.PLUGIN_START_ONLY)
annotation class LocalBean
@VersatiaEnhancement(LifecycleProcessors.AUTO_INVOKE, enhancementPhaseParamRefName = "phase")
annotation class AutoInvoke(val phase: Phase)
@Bean is a second marker that tells the compile-time step to treat annotated classes as beans:
analyse the constructor, compute the assignable types, validate the graph. An annotation that
inherits @Bean without a @VersatiaEnhancement is a compile error, because no processor would
ever see its beans.
@VersatiaAttribute
@Target(AnnotationTarget.CLASS, AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.BINARY)
@VersatiaAttribute
annotation class Qualifier(val value: String)
The descriptor records facts about the annotated code, never what a feature makes of them. An attribute is how a feature gets its facts in: the compile-time step records every attribute on an enhanced element and on its parameters, with all arguments (defaults included), and the processor reads them. Argument values may be strings, booleans, numbers, chars, enum entries (recorded by name), classes (recorded by name) and lists of those; anything else, such as a nested annotation, is a compile error at the place the attribute is used.
An annotation of your own annotated with an attribute counts as that attribute, transitively: the descriptor records the inherited occurrence next to yours, so a processor finds it as if it were written on the element, and the placement checks apply to it as well. A non-repeatable attribute may reach an element only once; see Your own attribute annotations.
Two attributes are read by the runtime itself rather than by a processor, because they are not
about one feature: @ConditionalOnPlugin decides whether an element is
dispatched at all, and @FriendlyName names the plugin in
the runtime's registry. The compile-time step checks their placement.
Processors
interface EnhancementProcessor {
fun process(elements: List<EnhancedElement>, context: EnhancementContext)
}
interface EnhancementContext {
val plugin: Plugin
val event: LifecycleEvent
val state: StateStore
val runtimeState: StateStore
fun onPluginEnhanced(action: () -> Unit)
}
The runtime loads processorClass with Class.forName through the enhanced plugin's class loader.
Bukkit's plugin loaders delegate to the loaders of the plugins under depend:, so the class may
be in VersatiaCore or in any plugin the enhanced one depends on: a plugin can ship its own
annotations and processors without touching VersatiaCore. One instance per class is created, with
the no-argument constructor or by taking the instance of a Kotlin object, and kept for the
lifetime of the runtime. All processors of a plugin are loaded before any of them runs, so a
missing one leaves the plugin untouched:
Plugin MyPlugin needs the enhancement processor com.example.MyProcessor, which is not available. Is the plugin providing it installed and listed under depend:?
A processor receives, per plugin and per lifecycle event, only the elements naming it whose phase
applies to the event. The context carries the Plugin being enhanced (its name, logger and class
loader follow from it; EnhancementContext.classLoader is a shorthand) and the event.
onPluginEnhanced registers an action that runs once every processor has seen the plugin's
elements for this event; the actions run in registration order, and one registered by another
action runs after those already queued. Processors run in the order of their first element in the
descriptor. The bean processors use it to instantiate all beans after all scopes have
declared theirs, and the auto-invoke processor to call functions after the beans exist.
Plugin state
Processors keep nothing per plugin in their own fields. context.state is a StateStore: a
store created for the plugin on first use, kept across START, RELOAD and STOP, shared by
every processor and dropped when the plugin is disabled. context.runtimeState is a second store
with the same interface that lives as long as VersatiaCore, for what spans plugins. Entries
implementing AutoCloseable are closed when their store goes, last created first. These stores
are the only way processors share anything, with each other or with their own later invocations:
// In VersatiaAPI: the contract every injecting processor uses
interface BeanResolver {
fun resolve(requester: String, parameters: List<Parameter>): List<Any>
fun instanceOf(className: String): Any?
companion object { val KEY = StateKey<BeanResolver>("beans") }
}
// In VersatiaCore: the DI processors keep the session under that key
class PluginBeanSession(...) : BeanResolver, AutoCloseable {
companion object {
fun of(context: EnhancementContext) =
context.state.getOrPut(BeanResolver.KEY) { PluginBeanSession(context.plugin, context.classLoader, ExposedBeanRegistry.of(context)) } as PluginBeanSession
}
}
The bean processors declare into that session; the auto-invoke processor, like any processor of
any plugin that wants injected parameters, reads context.state[BeanResolver.KEY]; the registry
of exposed beans sits in runtimeState; closing the session when the plugin is disabled
withdraws the beans it exposed. The runtime knows none of this; it only closes what is closable.
BeanResolver has three more members, supply, onInstanceCreated and instancesOf, through
which the @Value processor reaches into the bean container. They are marked
@VersatiaInternalApi, like the *Processors name
holders of the built-in features: a processor of your own needs only resolve, instanceOf and
the state stores, and using a marked member warns.
The built-in processors, all in VersatiaCore:
Processor class (dev.versatia…) | Handles |
|---|---|
.di.LocalBeanEnhancementProcessor | ClassTargets of @LocalBean |
.di.PluginBeanEnhancementProcessor | the PluginTarget of the main class, sent by @PluginBean |
.di.ExposeSingletonEnhancementProcessor | ClassTargets of @ExposeSingleton |
.di.ExposePrototypeEnhancementProcessor | ClassTargets of @ExposePrototype |
.lifecycle.AutoInvokeEnhancementProcessor | FunctionTargets of @AutoInvoke |
.config.ValueEnhancementProcessor | PropertyTargets of @Value |
.resources.ExtractResourcesEnhancementProcessor | the PluginTarget of the main class, sent by @ExtractResources |
.event.RegisterListenersEnhancementProcessor | every Listener of the plugin, sent by @RegisterListeners |
.task.RunBackgroundTasksEnhancementProcessor | every BackgroundTask of the plugin, sent by @RunBackgroundTasks |
.task.PluginScopeEnhancementProcessor | the PluginTarget of the main class, twice, sent by @RunBackgroundTasks: at start and at stop |
Defining your own scope
The supported way to extend the framework from a plugin without writing a processor is a scope annotation that inherits a built-in one. It costs one declaration and changes nothing at runtime:
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.BINARY)
@LocalBean
annotation class Service
The descriptor records @Service as the producing annotation (with its arguments, if any), while
the processor class and phase come from the inherited @LocalBean. An annotation may inherit two
enhancements and then produces two elements per occurrence.
Enhancements on a base class
A class enhancement written on the main class, or on any of its supertypes, base classes and
interfaces alike, produces an element
with the main class as its PluginTarget. That is how VersatiaPlugin gives every plugin its
bean container (@PluginBean), its resources (@ExtractResources) and its
coroutine scope (@RunBackgroundTasks), and it works for your own base classes too:
@Audited("console")
abstract class AuditedPlugin : VersatiaPlugin()
class MyPlugin : AuditedPlugin() // the descriptor carries an @Audited element for MyPlugin
Only the main class collects enhancements from its supertypes; beans are described by the annotations written on them. The nearest declaration wins: when the same enhancement annotation appears on the main class and on a base class, the main class's occurrence is the one recorded, with its own arguments.
A feature declared on a base class can be switched off further down. @DisableVersatiaFeatures
holds one Boolean per switchable framework feature; setting it to true on the main class or on
a base class leaves the enhancement out of the descriptor. What every plugin inherits from
VersatiaPlugin:
| Enhancement | Does | Switch |
|---|---|---|
@PluginBean | makes the main class the bean plugin | none |
@ExtractResources | extracts the plugin's resources | extractResources |
@RegisterListeners | registers every Listener | registerListeners |
@RunBackgroundTasks | runs every BackgroundTask and cancels the plugin's coroutines before its stop functions | runBackgroundTasks |
The mapping from a parameter to the enhancement it switches off is
@VersatiaDisabler(Enhancement::class) on that parameter, so a library may define a switch for its
own base-class enhancements the same way:
annotation class DisableAuditing(@VersatiaDisabler(Audited::class) val audited: Boolean = true)
Plugin enhancements and switches are accepted only on the main class and on its base classes
(subclasses of JavaPlugin); anywhere else they are a compile error, as is a switch that turns
nothing off and a class that both carries and switches off the same enhancement.
Subclass enhancements
@VersatiaSubclassEnhancement is the second way to declare an enhancement. Instead of waiting
for the annotation to be written on each class, it names a type and enhances every class in the
plugin assignable to it:
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.BINARY)
@VersatiaSubclassEnhancement("com.example.audit.AuditEnhancementProcessor", Auditable::class, Phase.PLUGIN_START_ONLY)
annotation class AuditSubclasses
@AuditSubclasses
abstract class AuditedPlugin : VersatiaPlugin() // every Auditable in a plugin extending it is audited
The carrying annotation is written on the main class or on a base class, like any plugin
enhancement, and can be switched off the same way. Each matching class becomes an element as if
the annotation were written on it: a concrete class with one injectable constructor as a
ClassTarget (validated like a bean, with its dependencies checked at compile time), an object
as a ClassTarget with an object instantiator, the main class as its PluginTarget. Abstract
classes and interfaces are skipped. The element's origin names the class the carrying annotation
was written on (AuditedPlugin above), so a processor can tell the elements of one rule from
another. A class that is both a bean and a match produces both elements; a processor needing the
instance asks context.state[BeanResolver.KEY] first and instantiates only when there is no bean.
VersatiaCore's own subclass processors also share the instance they create: a plain class that is
a Listener and a BackgroundTask is created once per plugin and both registered and run.
One annotation may carry a subclass enhancement and plugin enhancements at once.
@RunBackgroundTasks is the example:
@VersatiaSubclassEnhancement(TaskProcessors.RUN_BACKGROUND_TASKS, BackgroundTask::class, Phase.PLUGIN_RELOAD_OR_START)
@VersatiaEnhancement(TaskProcessors.PLUGIN_SCOPE, enhancementPhase = Phase.PLUGIN_START_ONLY)
@VersatiaEnhancement(TaskProcessors.PLUGIN_SCOPE, enhancementPhase = Phase.PLUGIN_STOP)
annotation class RunBackgroundTasks
Written on VersatiaPlugin, it gives every plugin one element per BackgroundTask class and two
PluginTarget elements for the scope processor, one per phase; its switch removes all of them.
Defining your own enhancement
A new feature is an annotation, optionally some attributes, and a processor. The conventions a processor follows (naming, packages, state, errors) are collected in Best practices.
// In a library plugins compile against
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.BINARY)
@VersatiaEnhancement("com.example.timing.TimedEnhancementProcessor", enhancementPhase = Phase.PLUGIN_START_ONLY)
annotation class Timed(val everyTicks: Long)
// In the plugin that provides the feature; other plugins list it under depend:
class TimedEnhancementProcessor : EnhancementProcessor {
override fun process(elements: List<EnhancedElement>, context: EnhancementContext) = context.onPluginEnhanced {
for (element in elements) {
val target = element.targetAs<FunctionTarget>(this)
val ticks = element.annotation.arguments["everyTicks"] as Long
val instance = context.receiverOf(target)
val arguments = context.inject(target.description, target.parameters)
val classLoader = context.classLoader // the context lives for one event; the task keeps only this
context.plugin.runTaskTimer(periodTicks = ticks, delayTicks = ticks) { target.invoker.invoke(classLoader, instance, arguments) }
}
}
}
The work is deferred with onPluginEnhanced, so the beans exist when it runs. The rest is done by
the helpers Core's own processors use, so a custom processor gives the same rules and messages:
| Helper | What it does |
|---|---|
element.targetAs<FunctionTarget>(this) | the target as the kind the processor accepts; any other fails with P can only enhance functions, got the property Shop.tax |
context.receiverOf(target) | the instance a function or property belongs to, by its owner: the plugin, the object, or the plugin's bean of the declaring class (Plugin X: no bean Shop exists to call Shop.open() on otherwise) |
context.inject(requester, parameters) | the arguments from the plugin's BeanResolver, with the rules of bean constructors; empty for no parameters, Plugin X: Shop.open() needs injected parameters, but the plugin has no beans when there are some and no beans |
context.failure(message, cause) | an EnhancementException reading Plugin X: message, for the processor's own errors |
FunctionTarget and PropertyTarget are both a MemberTarget (binaryClassName, owner,
classAnnotations and description, the Shop.open() or Shop.tax form messages use), and
every target has annotations, so a processor handles members alike.
The compile-time step records every function carrying @Timed as a FunctionTarget whose
annotation holds everyTicks; the runtime loads TimedEnhancementProcessor through the enhanced
plugin's class loader; injected parameters come from the plugin's BeanResolver, the same contract
@AutoInvoke uses. Nothing in VersatiaCore changes. The compile-time step still enforces the general
rules on enhanced functions (no extension, suspend, abstract or generic functions, a suspend one
pointing to BackgroundTask and PluginScope; injectable
parameters), on enhanced properties (a var of the main class, an object or a bean) and on
enhanced classes.
How the compile-time step works
The ksp module of VersatiaAPI runs once per compilation:
- It locates the plugin's main class: the single concrete
JavaPluginsubclass in the sources, or themain:entry of the file named by the KSP optionversatia.pluginYmlwhen it is set. - It walks every class declaration in the sources (compile-time scanning is fine, runtime scanning
is not) and, for each annotation on a class, resolves the annotation class and checks whether it
inherits
@Bean. Hits become bean declarations: type, name, scope occurrence, enhancements (processor class and phase), attributes, constructor parameters with their attributes, assignable types, reachability. The main class becomes a plugin declaration under the fixed nameplugin. - It collects functions annotated with an enhancement, checking their owner kind and parameters,
and properties annotated with one, checking their owner kind, mutability and, for
@Value, their type. - It validates the bean graph, the plugin included: duplicate names, qualifier mismatches, ambiguity, cycles.
- It builds the descriptor with KotlinPoet and writes it through KSP's
CodeGeneratoras an aggregating output depending on all source files.
Any error stops the build and suppresses the descriptor. The module mirrors the feature packages
of the API (ksp.di, ksp.lifecycle, ksp.config); the generic parts, among them the resolution of inherited
enhancements and the recording of attributes, sit in ksp itself.