min
09/14/2026, 9:49 AMwhen (declaration.primaryConstructorIfAny(context.session)) {
null -> reporter.reportOn(
declaration.source,
KtDiagnosticFactory0(
name = "",
severity = Severity.ERROR,
defaultPositioningStrategy = TODO(),
psiType = TODO(),
rendererFactory = TODO(),
),
context,
)
// ...
}
What kind of arguments does KtDiagnosticFactory0 expect? I can’t find its docs. I want my plugin to cause a compiler error if an annotated class doesn’t have a primary constructor.dmitriy.novozhilov
09/14/2026, 9:55 AMKtDiagnosticsContainer. After declaration you also need to register messages for your diagnostics.
Check how it's done in `kotlinx.serialization`:
• diagnostic factories themselves: FirSerializationErrors.kt
• diagnostic messages: KtDefaultErrorMessagesSerialization.ktmin
09/15/2026, 9:24 AMobject MyDiagnosticsContainer: KtDiagnosticsContainer() {
val MY_ERROR by error2<KtElement, String, String>()
override fun getRendererFactory(): BaseDiagnosticRendererFactory {
return object: BaseDiagnosticRendererFactory() {
override val MAP by KtDiagnosticFactoryToRendererMap("MyPlugin") { map ->
}
}
}
}
What’s the difference between error{0, 1, 2, ...}? If it is documented anywhere, could you please point me to it (sorry) as I’m unable to find it myself? I vaguely understand that MyDiagnosticsContainer#getRendererFactory should return an object that holds a populated map, but I’m not 100% sure what to do.dmitriy.novozhilov
09/15/2026, 9:35 AMerror0/1/2/3 define how many additional arguments you could provide to diagnostics.
For example, there is a warning0 named REDUNDANT_ELSE_IN_WHEN, which marks the else branch as redundant. It has the message 'when' is exhaustive so 'else' is redundant here. which is always the same.
On the other hand there is an UNINITIALIZED_VARIABLE, which is error1 with the message Variable ''{0}'' must be initialized.. When you create a specific diagnostic, you pass the symbol of the variable, which is uninitialized, so the actual message which user will see will look like Variable ''x'' must be initialized. (or whatever the variable is named).dmitriy.novozhilov
09/15/2026, 9:37 AMreporter.reportOn(branch.source, FirErrors.REDUNDANT_ELSE_IN_WHEN)
reporter.reportOn(expression.source, FirErrors.UNINITIALIZED_VARIABLE, symbol) // symbol is an argumentdmitriy.novozhilov
09/15/2026, 9:38 AMDiagnosticRendererFactory, you need to provide renderers for each argument this diagnostic factory has. The renderer is basically T -> String conversion.dmitriy.novozhilov
09/15/2026, 9:39 AMBaseDiagnosticRendererFactory a proper named object, not the anonymous one. It would be good for performance (there is no need to create this object on each call). And IIRC there is some code in the compiler which relies on identity of these factories, so creating new objects each time could break it.min
09/16/2026, 9:46 AMobject MyDiagnosticsContainer: KtDiagnosticsContainer() {
val MY_ERROR by error0<KtElement>()
override fun getRendererFactory(): BaseDiagnosticRendererFactory {
// inlining breaks the compiler
return MyDiagnosticsContainer.GetRendererFactoryOutput
}
object GetRendererFactoryOutput: BaseDiagnosticRendererFactory() {
override val MAP: KtDiagnosticFactoryToRendererMap
by KtDiagnosticFactoryToRendererMap("Fence") { map ->
map.put(MyDiagnosticsContainer.MY_ERROR, message = "asdf")
}
}
}
Thank you for the explanation. I couldn’t navigate this alone because nothing seems to be documented, everything is a DSL, and only some of equivalent solutions work.
class MyFirAdditionalCheckersExtension(
session: FirSession,
mppKind: MppCheckerKind,
): FirAdditionalCheckersExtension(session) {
override val declarationCheckers: DeclarationCheckers = object: DeclarationCheckers() {
override val regularClassCheckers = setOf(object: FirRegularClassChecker(mppKind) {
context(context: CheckerContext, reporter: DiagnosticReporter)
override fun check(declaration: FirRegularClass) {
when (val myAnnotation = MyAnnotation.on(
maybeAnnotatedClass = declaration.symbol,
session = context.session,
)) {
// Either<FenceAnnotation, FactoryError>
is Either.Foo -> return
is Either.Bar -> myAnnotation.bar.report(reporter, context)
}
}
})
}
}
I’ve also added an implementation of FirAdditionalCheckersExtension like the serialisation example you’ve showed me. The FactoryError#report method currently has the following scaffold implementation.
fun report(reporter: DiagnosticReporter, context: CheckerContext) {
when (this) {
is FactoryError.ClassNotAnnotated -> reporter.reportOn(
this.decl.source,
MyDiagnosticsContainer.MY_ERROR,
context,
)
is FactoryError.ArgumentMissing -> reporter.reportOn(
this.annotatedClass.source,
MyDiagnosticsContainer.MY_ERROR,
context,
)
is FactoryError.ArgumentInvalid -> reporter.reportOn(
this.annotatedClass.source,
MyDiagnosticsContainer.MY_ERROR,
context,
)
is FactoryError.PrimaryConstructorMissing -> reporter.reportOn(
this.annotatedClass.source,
MyDiagnosticsContainer.MY_ERROR,
context,
)
is FactoryError.PrimaryConstructorExposed -> reporter.reportOn(
this.primaryConstructor.source,
MyDiagnosticsContainer.MY_ERROR,
context,
)
}
}
I don’t understand why <http://MyDiagnosticsContainer.MY|MyDiagnosticsContainer.MY>_ERROR can’t be inlined (replaced with error0<KtElement>())dmitriy.novozhilov
09/16/2026, 9:51 AMfun report(reporter: DiagnosticReporter, context: CheckerContext) {
val source = when (this) {
is FactoryError.ClassNotAnnotated -> this.decl.source
is FactoryError.ArgumentMissing -> this.annotatedClass.source
is FactoryError.ArgumentInvalid -> this.annotatedClass.source
is FactoryError.PrimaryConstructorMissing -> this.annotatedClass.source
is FactoryError.PrimaryConstructorExposed -> this.primaryConstructor.source
}
reporter.reportOn(source, MyDiagnosticsContainer.MY_ERROR, context)
}
I don’t understand whyYou need to access each diagnostic at least in two places: • declaring a message for it • in the place where you report it (which could be more than one) It is possible to use the name of the factory as a unique identifier, but it's much more error prone and less handy than having each factory to be represented as a proper resolvable property in the code.can’t be inlined (replaced with<http://MyDiagnosticsContainer.MY|MyDiagnosticsContainer.MY>_ERROR)error0<KtElement>()
min
09/16/2026, 9:55 AM<http://MyDiagnosticsContainer.MY|MyDiagnosticsContainer.MY>_ERROR is just a standin, each FactoryError type will get its own kind of diagnostic to report later, so I can’t have a single reporter.reportOn(source, <http://MyDiagnosticsContainer.MY|MyDiagnosticsContainer.MY>_ERROR, context) statement, since the second argument to this call also depends on the factory error.min
09/16/2026, 9:56 AMKtDiagnosticsContainer#getRendererFactory the only way to associate a diagnostic with an error message?dmitriy.novozhilov
09/16/2026, 9:57 AMmin
09/16/2026, 10:02 AMval MY_ERROR by _error0_<KtElement>() permitted as an object member but not as a local variable in a function?dmitriy.novozhilov
09/16/2026, 10:06 AMerrorN/warningN functions, there is a context parameter of type KtDiagnosticsContainer.
So you can use it only if this context is provided:
context(_: KtDiagnosticsContainer)
fun foo() {
val MY_ERROR by error0<KtExpression>()
}
fun bar(container: KtDiagnosticsContainer) {
with(container) {
val MY_ERROR by error0<KtExpression>()
}
}
The thing is that these utility functions not only create an instance of the factory, but also register this factory in the container.min
09/16/2026, 10:09 AMsealed class FactoryError {
class ClassNotAnnotated(
val decl: FirClassSymbol<*>,
): FactoryError() {
companion object { val ERROR by error0<KtElement>() }
}
class ArgumentMissing(
val fencedClass: FirClassSymbol<*>,
): FactoryError() {
companion object { val ERROR by error0<KtElement>() }
}
// ...
}
So should I make the `companion object`s extend KtDiagnosticsContainer()?min
09/16/2026, 10:10 AMMAP shouldn’t be per FactoryError type though. It should be a unified map, shouldn't it?dmitriy.novozhilov
09/16/2026, 10:15 AMFirExtensionRegistrar, so it could be not very scalable.dmitriy.novozhilov
09/16/2026, 10:18 AMsealed class FactoryError {
abstract val diagnostic: KtDiagnosticFactory0
class ClassNotAnnotated(
val decl: FirClassSymbol<*>,
): FactoryError() {
override val diagnostic: KtDiagnosticFactory0 get() = MyErrors.MY_NOT_ANNOTATED
}
class ArgumentMissing(
val fencedClass: FirClassSymbol<*>,
): FactoryError() {
override val diagnostic: KtDiagnosticFactory0 get() = MyErrors.MY_ARGUMENT_MISSING
}
// ...
}
object MyErrors : KtDiagnosticsContainer() {
val MY_NOT_ANNOTATED by error0<KtElement>()
val MY_ARGUMENT_MISSING by error0<KtElement>()
override fun getRendererFactory(): BaseDiagnosticRendererFactory = TODO()
}
So it would be one diagnostic container, but each of `FactoryError`s would know its diagnostic factory.min
09/16/2026, 10:22 AMdiagnostic an instance member?dmitriy.novozhilov
09/16/2026, 10:23 AMget() = in implementations, which means that no backing field is created. So it's basically the same as override fun getDiagnosticFactory() = <http://MyErrors.MY|MyErrors.MY>_NOT_ANNOTATED.min
09/16/2026, 10:24 AM