Kotlin Compiler Plugin

Say hello to Kotlin Compiler Plugin

KSP 보다 훨씬 강력한 Kotlin Compiler Plugin 개발 시작하기 그리고 왜?

Ji Sungbin
성빈랜드

--

[View in English]

Photo by Perry Merrity II on Unsplash

Kotlin Compiler Plugin 시리즈는 총 3편으로 구성하고 있으며 이 글은 해당 시리즈의 첫 번째 글입니다. 이번 글에서는 코틀린 컴파일러의 구조를 간단하게 알아보고, 코틀린 컴파일러 플러그인으로 “Hello, World!”를 만들어 보겠습니다.

What is Kotlin Compiler Plugin?

아마 이 글을 보고 계신 분들 중 반 이상이 코틀린 컴파일러 플러그인 학습을 처음 도전하시는 거라 생각합니다. 코틀린 컴파일러 플러그인은 당연하게도 몰라도 전혀 문제가 되지 않으며 API 문서도 대부분 없습니다. 하지만 제가 소개하는 이유는 코틀린 컴파일러 플러그인을 활용하면 언어 차원에서 불가능한 기능을 가능으로 만들 수 있기 때문입니다.

코틀린 컴파일러 플러그인은 말 그대로 코틀린 컴파일러에 플러그인 기능을 추가하는 기술입니다.

저는 KSP의 단점을 극복하기 위해 코틀린 컴파일러 플러그인을 사용하게 됐습니다. 제가 구현하고자 하는 기능은 함수 인자의 default value를 조회하는 기능이 필요했는데, KSP API로는 함수 인자의 default value에 접근할 수 없었습니다. (KSP Internal API를 사용하면 가능하긴 하지만 제가 원하는 기능을 구현할 수는 없습니다.)

위 기능이 자세히 궁금하신 분은 아래 글을 확인해 주세요.

저는 코틀린 컴파일러 플러그인을 공부한 지 3일 만에 원하는 기능을 바로 구현할 수 있었습니다. 단지 문서가 거의 없을 뿐이지 핵심 흐름만 이해하면 생각보다 쉽게 코드를 작성할 수 있으니 천천히 학습해 봅시다.

Kotlin Compiler Plugin vs. Kotlin Symbol Processing

KSP API를 사용해 보신 분은 코틀린 컴파일러 플러그인을 비교적 쉽게 이해하실 수 있습니다. KSP 공식 문서에서는 KSP를 다음과 같이 설명하고 있습니다.

Kotlin Symbol Processing (KSP) is an API that you can use to develop lightweight compiler plugins. KSP provides a simplified compiler plugin API that leverages the power of Kotlin while keeping the learning curve at a minimum. Compared to kapt, annotation processors that use KSP can run up to 2 times faster.

즉, KSP API를 사용해 본 경험이 있다면 코틀린 컴파일러 플러그인의 일부를 이미 사용해 본거라 말할 수 있습니다. 그렇다면 KSP와 코틀린 컴파일러 플러그인의 차이점은 무엇일까요?

이번에는 KSP 공식 문서에서 말하는 KSP의 한계을 보겠습니다.

  • Examining expression-level information of source code.
  • Modifying source code.
  • 100% compatibility with the Java Annotation Processing API.

코틀린 컴파일러 플러그인과 연관 있는 항목은 첫 번째와 두 번째 항목인데요, 코틀린 컴파일러 플러그인은 언어 컴파일러의 플러그인으로 작동되기에 이 한계들이 모두 가능해집니다.

다시 말하면, 코틀린 컴파일러 플러그인을 사용하면 컴파일러의 입장에서 코드를 볼 수 있고, 없던 코드를 컴파일 과정에서 만들어낼 수도 있으며, 기존 코드를 수정할 수도 있고, 코틀린 언어 기능 중 일부를 마음대로 바꿀 수도 있습니다. 하지만 KSP에서는 이 모든게 불가능합니다.

코틀린 컴파일러 플러그인은 이런 상황에서 유용하게 사용할 수 있습니다.

  • 기능 구현에 투자할 수 있는 시간이 충분히 있고, (API 문서가 대부분 없어 학습에 오랜 시간이 걸릴 수 있습니다.)
  • 낮은 추상화의 수준에서 언어 정보에 접근해야 한다.
  • 또한 컴파일러 시점에서 기존 코드를 수정하거나 새로운 코드를 생성해야 한다.

하지만 이런 상황에서는 KSP가 더 좋은 선택지가 됩니다.

  • 기능 구현에 투자할 수 있는 시간이 충분하지 않고, (문서가 많아 쉽게 학습할 수 있습니다.)
  • 높은 추상화의 수준에서 쉽고 빠르게 언어 정보에 접근해야 한다.
  • 또한 새로운 코드를 생성하기만 하고 기존 코드를 수정하지는 않는다.

지금까지 코틀린 컴파일러 플러그인의 의미와 왜 사용하는지를 알아보았습니다.

Kotlin Compiler Under the Hood

이제부터는 코틀린 컴파일러 플러그인 개발에 필요한 사전 지식인 코틀린 컴파일러의 내부 작동을 알아보겠습니다. (코틀린 컴파일러를 이미 어느 정도 아시는 분께 말하자면 이 글은 현재 시점에서 기본 백엔드인 New IR Backend를 기준으로 작성하였습니다.)

코틀린 컴파일러는 역할에 따라 frontend와 backend 단계로 나뉩니다.

  • frontend: PSI Tree 구축 및 BindingContext 구성
  • backend: IR 생성 및 타겟/머신 코드 생성

frontend 단계부터 보겠습니다. 코틀린 컴파일러는 frontend 단계에서 가장 먼저 PSI Tree를 구축하는 과정을 시작합니다. PSI란 Program Structure Interface의 약어이며, 소스 코드 구문 분석의 결과를 나타냅니다. PSI는 구문 분석의 결과일 뿐이지 의미 정보(semantic info)를 포함하지 않는다는 게 중요합니다.

의미 정보란 코드에 사용된 모든 데이터의 세부 정보를 의미하며, ‘이 함수의 출처는 어디인가?’, ‘이 변수가 모두 동일한 값을 참조하는가?’, ‘이 타입은 무엇인가?’와 같은 물음에 답을 제공합니다.

예를 들어 다음과 같은 간단한 코드가 있습니다.

fun main() {
if (pet is Dog) {
pet.woof()
} else {
println("*")
}
}

위 코드는 PSI Tree가 아래와 같이 구축됩니다.

위 트리에서 리프 노드에 해당하는 'pet', 'Dog', 'pet', 'woof', 'println', '*' 노드는 단순히 문자열을 나타내지 해당 노드가 어떤 의미 정보를 가지고 있는지는 알 수 없습니다. 다시 말해서 위 PSI Tree를 코드로 바꿔보면 아래와 같이 됩니다.

fun main() {
if ("pet" is "Dog") {
"pet"."woof()"
} else {
"println"("*")
}
}

각각 노드가 나타내는 의미 정보는 BindingContext라는 특수 맵에 저장됩니다.

코틀린 컴파일러의 frontend 단계에서는 코틀린 소스 코드를 분석하여 PSI Tree를 구축하고, 각각 노드별 의미 정보를 BindingContext에 저장하는 절차가 진행됩니다.

frontend 단계가 모두 완료되면 해당 결과를 가지고 backend 단계가 진행됩니다.

  • JS IR BackendIR generatorJavaScript (js file)
  • JVM IR BackendIR generatorJVM Bytecode (class file)
  • Native BackendIR generatorLLVM Bitcode (so file)

위 과정을 보면 사용하는 백엔드 엔진이 js, jvm, native로 3가지이고, 중간에 IR generator라는 과정은 3가지 엔진 모두에 공통되는 걸 볼 수 있습니다.

코틀린은 멀티플랫폼 언어이기에 각각 플랫폼에 맞는 개별 엔진을 두고 있으며, 플랫폼 엔진끼리 로직을 공유하기 위해 IR을 사용합니다.

IR은 Intermediate Representation의 약어이며, 코틀린 소스 코드와 대상 코드(.js, .class, .so) 사이의 중간 표현입니다. IR을 사용하면 대상 코드 플랫폼별로 모두 공통되는 중간 표현을 가져올 수 있기에, 이 중간 표현에 맞는 로직을 구현하는 걸로 많은 중복 코드를 예방할 수 있습니다.

코틀린 컴파일러의 backend 단계에서는 frontend 단계에서 준비되는 PSI Tree와 BindingContext를 기반으로 IR을 생성하고, 생성한 IR을 활용하여 대상 코드를 생성하는 절차가 진행됩니다.

지금까지 코틀린 컴파일러의 내부 동작을 간단히 알아보았습니다.

Hello, World!

이제 코틀린 컴파일러 플러그인을 만들기 위한 기초 개념이 모두 준비되었습니다. 코틀린 컴파일러 플러그인은 다음과와 같은 구조를 띕니다.

CommandLineProcessor는 컴파일러 인자로 볼 수 있고, CompilerPluginRegistrar는 컴파일러 플러그인의 통합 지점으로 볼 수 있습니다. 컴파일러 플러그인을 의미하는 ExtensionCompilerPluginRegistrar에 등록하는 과정으로 코틀린 컴파일러 플러그인이 구성됩니다.

이제 나만의 코틀린 컴파일러 플러그인을 만들어 보겠습니다. 모듈에 정의된 모든 함수의 시그니처를 출력하는 간단한 플러그인을 만들어 봅시다.

먼저 코틀린 컴파일러와 autoservice 의존성이 필요합니다.

dependencies {
compileOnly("org.jetbrains.kotlin:kotlin-compiler-embeddable:1.8.20")
compileOnly("com.google.auto.service:auto-service-annotations:1.0.1")
kapt("com.google.auto.service:auto-service:1.0.1")
}

코틀린 컴파일러는 코틀린 컴파일 과정에서만 유효하므로 compileOnly로 추가해 주었고, 앞서 소개한 CommandLineProcessorCompilerPluginRegistrar는 ServiceLoader로 등록되므로 쉬운 서비스 추가를 위해 autoservice를 추가해 주었습니다.

CommandLineProcessor부터 구현해 보겠습니다. CommandLineProcessor는 컴파일러 인자라고 하였습니다. 따라서 다음과 같은 2가지 항목이 필요합니다.

  1. 컴파일러 플러그인 아이디
  2. 컴파일러 인자 정보

이 글에서는 컴파일러 플러그인 아이디로 land.sungbin.function.printer를 사용하고, 컴파일러 인자로는 String 타입의 tag를 추가하도록 하겠습니다. 결과를 미리 보자면 다음과 같이 컴파일러 인자를 제공하게 됩니다.

tasks.withType<KotlinCompile> {
val functionPrinterPluginId = "land.sungbin.function.printer"
kotlinOptions {
freeCompilerArgs = freeCompilerArgs + listOf(
"-P",
"plugin:$functionPrinterPluginId:tag=FP",
)
}
}

각각 항목에 맞는 변수를 만들어 주겠습니다.

const val PluginId = "land.sungbin.function.printer"

val KEY_TAG = CompilerConfigurationKey<String>("Tags to use for logging")
val OPTION_TAG = CliOption(
optionName = "tag",
valueDescription = "String",
description = KEY_TAG.toString(),
)

컴파일러 인자 키는 CompilerConfigurationKey<인자 타입>(인자 설명)으로 정의할 수 있고, 인자 키의 옵션은 CliOption(optionName = 인자명, valueDescription = 인자 값 설명, description = 인자 설명)으로 정의할 수 있습니다.

OPTION_TAG를 보면 CliOption#description 값으로 KEY_TAG.toString()을 주고 있는데, CompilerConfigurationKey#toString으로 해당 인자 키에 부여된 인자 설명을 가져올 수 있습니다.

이제 각각 변수를 CommandLineProcessor에 제공해 줍시다.

@AutoService(CommandLineProcessor::class)
class FPCommandLineProcessor : CommandLineProcessor {
override val pluginId = PluginId

override val pluginOptions = listOf(OPTION_TAG)

override fun processOption(
option: AbstractCliOption,
value: String,
configuration: CompilerConfiguration,
) {
when (val optionName = option.optionName) {
OPTION_TAG.optionName -> configuration.put(KEY_TAG, value)
else -> error("Unknown plugin option: $optionName")
}
}
}

컴파일러 인자로 제공된 값은 CommandLineProcessorprocessOption 콜백으로 전달됩니다. processOption 콜백의 optionvalue 인자는 각각 컴파일러 인자 옵션과 제공된 값을 의미합니다. 그리고 마지막 configuration 인자는 컴파일러에서 전역으로 사용할 구성이 담긴 맵을 의미합니다.

processOption으로 제공된 컴파일러 인자가 OPTION_TAG에 해당하는 인자가 맞다면 제공된 인자 값을 configuration 맵에 KEY_TAG 키로 저장하게 구현하였습니다.

만약 알 수 없는 컴파일러 인자가 제공됐다면 IllegalStateException을 throw 합니다.

다음으로 CompilerPluginRegistrar를 봅시다. CompilerPluginRegistrar는 컴파일러 플러그인의 통합 지점이라고 하였습니다. 따라서 이 곳에서 컴파일러 플러그인을 의미하는 Extension 등록이 진행됩니다.

@AutoService(CompilerPluginRegistrar::class)
class FPCompilerPluginRegistrar : CompilerPluginRegistrar() {
override val supportsK2 = false

override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) {
// configuration.get(key: CompilerConfigurationKey<T>, defaultValue: T (optional))

val logger = configuration.get(CLIConfigurationKeys.MESSAGE_COLLECTOR_KEY, MessageCollector.NONE)
val loggingTag = requireNotNull(configuration.get(KEY_TAG))
}
}

CompilerPluginRegistrarsupportsK2 변수와 ExtensionStorage.registerExtensions 확장 함수로 구성된 추상 클래스입니다.

supportsK2 변수는 코틀린 컴파일러의 새로운 버전을 지원할지를 나타내는 변수입니다. 이 글에서는 간단한 설명을 위해 K2 지원은 하지 않는걸로 하겠습니다. ExtensionStorage.registerExtensions 확장 함수는 Extension을 등록할 수 있는 환경을 열어줍니다. 즉, ExtensionStorage.registerExtensions 안에서 Extension 등록이 진행됩니다.

ExtensionStorage.registerExtensions 본문을 보면 함수 인자인 configuration으로부터 CLIConfigurationKeys.MESSAGE_COLLECTOR_KEY 키와 KEY_TAG 키로 loggerloggingTag를 가져오고 있습니다. KEY_TAGCommandLineProcessor에서 등록해 준 키므로 익숙한 반면, CLIConfigurationKeys.MESSAGE_COLLECTOR_KEY는 처음 등장하였습니다. 해당 키는 코틀린 컴파일러에서 기본으로 제공되는 키이며, 코틀린 컴파일러 환경에서 사용할 로거를 가져와줍니다.

이제 Extension을 등록할 차례입니다. Extension에는 다양한 종류가 있습니다. 대표적으로는 바이트코드 생성 과정에 접근할 수 있는 ExpressionCodegenExtension과 IR 생성 과정에 접근할 수 있는 IrGenerationExtension이 있습니다.

이 글의 목표는 모듈에 정의된 모든 함수의 시그니처를 출력하는 것입니다. 모듈에 존재하는 모든 함수를 조회하려면 코틀린 소스코드가 모두 해석되고 의미 정보 조회가 모두 끝난 시점인 IR generator 과정에 접근하는 게 가장 좋아 보입니다. 따라서 IrGenerationExtension을 사용하겠습니다.

class FPIrExtension(
private val logger: MessageCollector,
private val loggingTag: String,
) : IrGenerationExtension {
override fun generate(
moduleFragment: IrModuleFragment,
pluginContext: IrPluginContext,
) {
moduleFragment.accept(FPIrVisitor(logger, loggingTag), null)
}
}

IrGenerationExtensiongenerate 함수를 갖는 인터페이스이며, generate를 구현하는 것으로 IR 생성 과정에 접근할 수 있습니다. generate 함수의 인자로는 IrModuleFragmentIrPluginContext가 있는데, 각각 컴파일러가 작동 중인 모듈의 IR 정보와 IR 작업에 도움이 될 수 있는 context를 제공합니다.

IrModuleFragment와 같이 모든 IR 요소에는 accepttransform 함수가 있습니다. accept은 IR 변경 없이 IR 방문만 하는 경우에 해당하고, transform은 IR 방문과 동시에 변경까지 하는 경우에 해당합니다. 이 글의 경우라면 IR 방문만 하면 되니 accept 함수를 사용해 줍시다.

accept 함수의 인자로는 IrElementVisitor 구현체와 해당 구현체에 부과적으로 전달할 객체를 제공해야 합니다. 이 글에서는 IrElementVisitor 구현으로 FPIrVisitor라는 클래스를 만들어 사용하고, 부과적으로 전달한 객체는 없으니 null을 전달해 주겠습니다.

FPIrVisitorIrElementVisitorVoid를 상속받는 클래스입니다.

class FPIrVisitor(
private val logger: MessageCollector,
private val loggingTag: String,
) : IrElementVisitorVoid {
override fun visitModuleFragment(declaration: IrModuleFragment) {
TODO()
}

override fun visitFile(declaration: IrFile) {
TODO()
}

override fun visitFunction(declaration: IrFunction) {
TODO()
}
}

부과적으로 전달할 객체가 없으니 아무것도 전달받지 않는 용도로 설계된 IrElementVisitorIrElementVisitorVoid를 상속해 주었습니다.

IrElementVisitor는 모든 상황에 적합하도록 엄청 섬세한 단위로 IR 접근 콜백을 제공합니다. 모듈에 존재하는 모든 함수의 IR에 접근하기 위해선 우선 모듈 IR에 접근하고, 해당 모듈에 포함된 모든 파일을 순회하며, 해당 파일에 정의된 함수의 IR에 방문해야 합니다. 이를 위해 FPIrVisitor에서는 visitModuleFragment, visitFile, visitFunction 콜백을 사용합니다.

visitModuleFragment 콜백에서 인자로 주어진 모듈에 포함된 모든 코틀린 파일에 방문하도록 구현해 봅시다.

override fun visitModuleFragment(declaration: IrModuleFragment) {
declaration.files.forEach { file ->
file.accept(this, null)
}
}

IrModuleFragmentfiles 포로퍼티를 사용하여 파일의 IR을 나타내는 IrFile로 구성된 목록을 가져올 수 있습니다. IrFile 목록을 순회하며 해당 파일의 IR에 방문할 수 있도록 FPIrVisitor 자체를 다시 accept 해주고 있습니다. 그러면 FPIrVisitor가 다시 실행되면서 주어진 IrFilevisitFile 콜백이 호출됩니다.

override fun visitFile(declaration: IrFile) {
declaration.declarations.forEach { item ->
item.accept(this, null)
}
}

visitFile 콜백에서는 해당 파일에 정의된 모든 요소를 조회하기 위해 인자로 주어지는 IrFiledeclarations 프로퍼티를 사용할 수 있습니다. declarations 프로퍼티로 얻어지는 IrDeclaration 목록을 순회하며 해당 요소가 함수에 속한다면 visitFunction 콜백이 실행될 수 있도록 FPIrVisitor 자체를 다시 한번 accept 해줍니다.

override fun visitFunction(declaration: IrFunction) {
val render = buildString {
append(declaration.fqNameWhenAvailable!!.asString() + "(")
val parameters = declaration.valueParameters.iterator()
while (parameters.hasNext()) {
val parameter = parameters.next()
append(parameter.name.asString())
append(": ${parameter.type.classFqName!!.shortName().asString()}")
if (parameters.hasNext()) append(", ")
}
append("): " + declaration.returnType.classFqName!!.shortName().asString())
}
logger.report(CompilerMessageSeverity.WARNING, "[$loggingTag] $render")
}

최종적으로 도달하게 될 visitFunction 콜백에서는 더 이상 accept은 없고 인자로 주어지는 IrFunction의 시그니처 조회를 진행합니다. 함수 시그니처를 조회함과 동시에 문자열로 나타내기 위해 buildString 안에서 로직을 작성해 봅시다.

먼저 함수의 fully-qualified name을 작성해 주고, 인자를 작성하기 위한 소괄호를 열어줍니다.

append(declaration.fqNameWhenAvailable!!.asString() + "(")

인자를 작성하기 위해 함수의 value parameter를 모두 조회합니다. 단, 마지막 인자 전까지만 쉼표를 표시하기 위해 iterator로 조회하였습니다.

함수 인자는 type parameter와 value parameter로 나뉩니다. 제네릭 부분을 type parameter라고 하고, 인자 부분을 value parameter라고 합니다.

val parameters = declaration.valueParameters.iterator()

이제 parameters를 순회하며 인자의 이름과 타입을 작성해 보겠습니다.

while (parameters.hasNext()) {
val parameter = parameters.next()
append(parameter.name.asString())
append(": ${parameter.type.classFqName!!.shortName().asString()}")
if (parameters.hasNext()) append(", ")
}

마지막으로 인자 괄호를 닫아주고, 함수의 반환 타입을 작성해 줍시다.

append("): " + declaration.returnType.classFqName!!.shortName().asString())

이렇게 해서 함수의 시그니처를 문자열로 나타내 보았습니다. 이제 출력할 차례입니다. 로그 출력은 FPIrVisitor 클래스의 인자로 받는 MessageCollectorreport 함수를 사용하여 가능합니다. report 함수의 인자로는 로그 레벨과 로그 메시지가 있습니다.

logger.report(CompilerMessageSeverity.WARNING, "[$loggingTag] $render")

FPIrVisitor가 완성되었습니다!

class FPIrVisitor(
private val logger: MessageCollector,
private val loggingTag: String,
) : IrElementVisitorVoid {
override fun visitModuleFragment(declaration: IrModuleFragment) {
declaration.files.forEach { file ->
file.accept(this, null)
}
}

override fun visitFile(declaration: IrFile) {
declaration.declarations.forEach { item ->
item.accept(this, null)
}
}

override fun visitFunction(declaration: IrFunction) {
val render = buildString {
append(declaration.fqNameWhenAvailable!!.asString() + "(")
val parameters = declaration.valueParameters.iterator()
while (parameters.hasNext()) {
val parameter = parameters.next()
append(parameter.name.asString())
append(": ${parameter.type.classFqName!!.shortName().asString()}")
if (parameters.hasNext()) append(", ")
}
append("): " + declaration.returnType.classFqName!!.shortName().asString())
}
logger.report(CompilerMessageSeverity.WARNING, "[$loggingTag] $render")
}
}

이제 FPIrVisitoraccept하는 FPIrExtensionCompilerPluginRegistrar에 등록해 줍시다.

override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) {
val logger = configuration.get(CLIConfigurationKeys.MESSAGE_COLLECTOR_KEY, MessageCollector.NONE)
val loggingTag = requireNotNull(configuration[KEY_TAG])

// new!
IrGenerationExtension.registerExtension(FPIrExtension(logger, loggingTag))
}

FPIrExtensionIrGenerationExtension을 상속하고 있었으므로 IrGenerationExtension.registerExtension으로 등록할 수 있습니다.

코틀린 컴파일러 플러그인 개발이 모두 끝났습니다. 이제 실행만 남았습니다.

코틀린 컴파일러 플러그인은 kotlinCompilerPluginClasspath configuration으로 추가할 수 있습니다. 하드코딩을 피하고 싶다면 코틀린 그레이들 플러그인에서 제공하는 상수인 PLUGIN_CLASSPATH_CONFIGURATION_NAME을 사용할 수 있습니다.

import org.jetbrains.kotlin.gradle.plugin.PLUGIN_CLASSPATH_CONFIGURATION_NAME

dependencies {
PLUGIN_CLASSPATH_CONFIGURATION_NAME(project(":function-printer-plugin"))
}

CommandLineProcessor에 등록한 컴파일러 인자 추가도 잊지 말고 해줍니다.

tasks.withType<KotlinCompile> {
val functionPrinterPluginId = "land.sungbin.function.printer"
kotlinOptions {
freeCompilerArgs = freeCompilerArgs + listOf(
"-P",
"plugin:$functionPrinterPluginId:tag=FP",
)
}
}

다음으로 테스트할 함수를 추가해 줍시다.

package land.sungbin.sample

fun helloWorld() = Unit
fun helloWorld2(arg: Any) = arg
fun helloWorld3(arg: Int, arg2: Float) = arg2

이제 ./gradlew build를 실행하면 지금까지 만든 코틀린 컴파일러 플러그인이 정상적으로 작동하는 걸 확인할 수 있습니다.

이 글에 사용된 프로젝트는 아래 깃허브에서 확인하실 수 있습니다.

긴 글 끝까지 읽어주셔서 감사합니다. 다음 편에서는 IR 변경을 해보도록 하겠습니다.

Reference

--

--

Ji Sungbin
성빈랜드

Experience Engineers for us. I love development that creates references.