Skip to main content

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. @Qualifier and @Primary are attributes.
  • Annotation inheritance: an annotation class annotated with another annotation inherits it, transitively. When @LocalBean is annotated with @Bean and @VersatiaEnhancement, every class marked @LocalBean counts as marked with both; a @Service annotated with @LocalBean inherits 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 = "",
)
  • processorClass is 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 in BeanProcessors, LifecycleProcessors, ConfigProcessors, ResourceProcessors, EventProcessors and TaskProcessors. A mistyped name is reported when the plugin enables.
  • Exactly one of enhancementPhase (fixed) and enhancementPhaseParamRefName (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.LocalBeanEnhancementProcessorClassTargets of @LocalBean
.di.PluginBeanEnhancementProcessorthe PluginTarget of the main class, sent by @PluginBean
.di.ExposeSingletonEnhancementProcessorClassTargets of @ExposeSingleton
.di.ExposePrototypeEnhancementProcessorClassTargets of @ExposePrototype
.lifecycle.AutoInvokeEnhancementProcessorFunctionTargets of @AutoInvoke
.config.ValueEnhancementProcessorPropertyTargets of @Value
.resources.ExtractResourcesEnhancementProcessorthe PluginTarget of the main class, sent by @ExtractResources
.event.RegisterListenersEnhancementProcessorevery Listener of the plugin, sent by @RegisterListeners
.task.RunBackgroundTasksEnhancementProcessorevery BackgroundTask of the plugin, sent by @RunBackgroundTasks
.task.PluginScopeEnhancementProcessorthe 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:

EnhancementDoesSwitch
@PluginBeanmakes the main class the bean pluginnone
@ExtractResourcesextracts the plugin's resourcesextractResources
@RegisterListenersregisters every ListenerregisterListeners
@RunBackgroundTasksruns every BackgroundTask and cancels the plugin's coroutines before its stop functionsrunBackgroundTasks

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:

HelperWhat 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:

  1. It locates the plugin's main class: the single concrete JavaPlugin subclass in the sources, or the main: entry of the file named by the KSP option versatia.pluginYml when it is set.
  2. 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 name plugin.
  3. 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.
  4. It validates the bean graph, the plugin included: duplicate names, qualifier mismatches, ambiguity, cycles.
  5. It builds the descriptor with KotlinPoet and writes it through KSP's CodeGenerator as 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.