Kotlin Compiler Plugin (kotlin-allopen) を追いかける

これは Kotlin AdventCalendar 2016 の 24 日目の記事です。メリークリスマスイブ!時間的にギリギリアウトでした・・・。

さて、少し前に Kotlin 1.0.6 RC が出ましたが、そのタイミングで kotlin-allopenkotlin-noarg という 2 つのコンパイラプラグインが発表されました。

discuss.kotlinlang.org

それぞれ、以下の働きをするコンパイラプラグインとなっています。

  • kotlin-allopen
    任意のアノテーション (ユーザーにて指定できます) が付いているクラスのメンバーに、暗黙的に open 修飾子を付与します。 Spring 等のフレームワークや Mockit などのライブラリに有益な物となっています。普段 Web アプリ開発に Kotlin + Spring Framework を使っているのでとても助かりそうです!

  • kotlin-noarg
    任意のアノテーション (ユーザーにて指定できます) が付いているクラスに、暗黙的に引数ゼロのコンストラクターを生成します。 主に JPA 対応として作成されたようです。

コンパイラプラグインの使い方は上のリンクを参照していただくとして、今回は kotlin-allopen プラグインを題材に、コードにコメントを追加する形でコンパイラプラグインの作りについて追いかけてみたいと思います。リンクは、タグ v1.1-M04 です。 (v1.0.6 RC はタグが切られてないようなので、2016/12/24 時点で一番新しいタグをベースにしました。)

全体の仕組み

kotlin-allopen プラグインに関するコードは以下のディレクトリにあります。kotlin-allopen の本体は共通化されており、Gradle と Maven 向けのプラグイン実装が用意されています。

今回はプラグイン本体のみ追いかけます。

プラグイン本体

ここから、プラグイン本体のコードを読んでいきます。コードは 2 ファイルのみで、大きくないためすぐ読めます。

AllOpenPlugin.kt

AllOpenPlugin.kt には、コンパイラプラグインに必要な 2 つのインターフェースを実装したクラスが定義されています。 コンパイラプラグインに必要なインターフェースは以下の 2 つです。

/*
 * Copyright 2010-2016 JetBrains s.r.o.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jetbrains.kotlin.allopen

import com.intellij.mock.MockProject
import org.jetbrains.kotlin.compiler.plugin.CliOption
import org.jetbrains.kotlin.compiler.plugin.CliOptionProcessingException
import org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor
import org.jetbrains.kotlin.compiler.plugin.ComponentRegistrar
import org.jetbrains.kotlin.config.CompilerConfiguration
import org.jetbrains.kotlin.config.CompilerConfigurationKey
import org.jetbrains.kotlin.extensions.DeclarationAttributeAltererExtension

/**
 * コンパイラープラグインで扱う設定のキーを定義するオブジェクトです。
 */
object AllOpenConfigurationKeys {

    /**
     * kotlin-allopen の対象とするアノテーションのリストを設定するキーです。
     */
    val ANNOTATION: CompilerConfigurationKey<List<String>> =
            CompilerConfigurationKey.create("annotation qualified name")

}

/**
 * kotlin-allopen プラグインに指定されたオプション引数を処理するクラスです。
 */
class AllOpenCommandLineProcessor : CommandLineProcessor {

    /**
     * AllOpenCommandLineProcessor のコンパニオンオブジェクトです。
     */
    companion object {

        /**
         * オプション引数 annotation の定義です。
         *
         *   - オプション引数名は annotation です。
         *   - オプション引数には、プラグイン処理の対象とするアノテーションの完全修飾名を指定します。
         *   - オプション引数は複数指定可能です。
         *
         * コマンドラインコンパイラーを使用する場合、以下の引数を指定してコンパイラープラグインを有効にします。
         *
         *   `-P <jar ファイルのパス>:<プラグイン ID>:<オプション引数名>=<アノテーションの完全修飾名>[, 複数指定する場合はカンマで区切る]`
         */
        val ANNOTATION_OPTION = CliOption("annotation", "<fqname>", "Annotation qualified names",
                                          required = false, allowMultipleOccurrences = true)

        /**
         * コマンドラインコンパイラーで指定するプラグイン ID を定義します。
         */
        val PLUGIN_ID = "org.jetbrains.kotlin.allopen"
    }

    /**
     * CommandLineProcessor インターフェースで定義されている変数です。
     * コンパニオンオブジェクトで定義したプラグイン ID を返します。
     */
    override val pluginId = PLUGIN_ID

    /**
     * CommandLineProcessor インターフェースで定義されている変数です。
     * オプション引数のリストを返します。
     */
    override val pluginOptions = listOf(ANNOTATION_OPTION)

    /**
     * CommandLineProcessor インターフェースで定義されているメソッドです。
     * 指定されたオプション引数を処理します。
     *
     * オプション引数に指定した数だけ繰り返し実行されます。
     *
     * @param option オプション引数を判別するための CliOption です。
     * @param value オプション引数に指定された値です。
     * @param configuration コンパイラーの設定等を保持するオブジェクトです。
     */
    override fun processOption(option: CliOption, value: String, configuration: CompilerConfiguration) = when (option) {
        /* オプション引数 annotation が指定された場合の処理です。 */
        ANNOTATION_OPTION -> {
            /* これまでに設定されたオプション引数 annotation のリストを、CompilerConfiguration から取得します。
             * 設定を取得するキーは、AllOpenConfigurationKeys オブジェクトで定義しています。 */
            val paths = configuration.getList(AllOpenConfigurationKeys.ANNOTATION).toMutableList()
            /* 今回の値をリストに追加します。 */
            paths.add(value)
            /* CompilerConfiguration の値を上書きします。 */
            configuration.put(AllOpenConfigurationKeys.ANNOTATION, paths)
        }
        /* kotlin-allopen プラグインが想定していないオプションが指定された場合は例外をスローします。 */
        else -> throw CliOptionProcessingException("Unknown option: ${option.name}")
    }
}

/**
 * kotlin-allopen プラグインの処理を登録します。
 */
class AllOpenComponentRegistrar : ComponentRegistrar {

    /**
     * ComponentRegistrar インターフェースで定義されているメソッドです。
     * コンパイラープラグインの処理を登録します。
     *
     * @param project Intellij IDEA で用意しているオブジェクトのようですが、あまり情報がないため不明です・・・。
     * @param configuration コンパイラーの設定等を保持するオブジェクトです。
     *                      AllOpenCommandLineProcessor で処理済みのものです。
     */
    override fun registerProjectComponents(project: MockProject, configuration: CompilerConfiguration) {
        /* CompilerConfiguration から、kotlin-allopen 処理の対象とするアノテーションのリストを取得します。
         * 取得できない場合はプラグイン処理を登録しません。 */
        val annotations = configuration.get(AllOpenConfigurationKeys.ANNOTATION) ?: return
        if (annotations.isEmpty()) return

        /* kotlin-allopen 処理を登録します。 */
        DeclarationAttributeAltererExtension.registerExtension(project, CliAllOpenDeclarationAttributeAltererExtension(annotations))
    }
}

AllOpenDeclarationAttributeAltererExtension.kt

AllOpenDeclarationAttributeAltererExtension.kt では、kotlin-allopen の処理をおこなうクラスを定義しています。

コンパイラーの拡張ポイント (という名前でいいのか?) は DeclarationAttributeAltererExtension というインターフェースです。

コンパイラーのフロントエンドの org.jetbrains.kotlin.extensions パッケージに定義されているインターフェース / クラスは数少ないのですが、まだ十分に用意されていないのかそれともこれで十分なのか、理解が足りず分かりません・・・。

/*
 * Copyright 2010-2016 JetBrains s.r.o.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.jetbrains.kotlin.allopen

import org.jetbrains.annotations.TestOnly
import org.jetbrains.kotlin.descriptors.ClassDescriptor
import org.jetbrains.kotlin.descriptors.DeclarationDescriptor
import org.jetbrains.kotlin.descriptors.Modality
import org.jetbrains.kotlin.extensions.AnnotationBasedExtension
import org.jetbrains.kotlin.extensions.DeclarationAttributeAltererExtension
import org.jetbrains.kotlin.lexer.KtTokens
import org.jetbrains.kotlin.psi.KtModifierListOwner
import org.jetbrains.kotlin.resolve.BindingContext

/**
 * コンストラクターで指定されたアノテーションに対してプラグイン処理をおこなうクラスです。
 *
 * @property allOpenAnnotationFqNames プラグイン処理をおこなうアノテーションのリストです。
 */
class CliAllOpenDeclarationAttributeAltererExtension(
        private val allOpenAnnotationFqNames: List<String>
) : AbstractAllOpenDeclarationAttributeAltererExtension() {

    /**
     * AnnotationBasedExtension インターフェースで定義されているメソッドです。
     * プラグイン処理をするアノテーションのリストを返します。
     */
    override fun getAnnotationFqNames(modifierListOwner: KtModifierListOwner?) = allOpenAnnotationFqNames

}

/**
 * プラグイン処理をおこなうクラスです。
 *
 * 2 つのインターフェースを実装しています。
 *
 *   - DeclarationAttributeAltererExtension
 *   - AnnotationBasedExtension
 */
abstract class AbstractAllOpenDeclarationAttributeAltererExtension : DeclarationAttributeAltererExtension, AnnotationBasedExtension {

    /**
     * AbstractAllOpenDeclarationAttributeAltererExtension のコンパニオンオブジェクトです。
     */
    companion object {

        /**
         * テスト用のアノテーションリストを定義します。
         */
        val ANNOTATIONS_FOR_TESTS = listOf("AllOpen", "AllOpen2", "test.AllOpen")

    }

    /**
     * DeclarationAttributeAltererExtension インターフェースで定義されているメソッドです。
     * 
     * Modality を変更するプラグイン処理を実装します。(たぶん・・・)
     *
     * @property modifierListOwner
     * @property declaration
     * @property containingDeclaration
     * @property currentModality
     * @property bindingContext
     */
    override fun refineDeclarationModality(
            modifierListOwner: KtModifierListOwner,
            declaration: DeclarationDescriptor?,
            containingDeclaration: DeclarationDescriptor?,
            currentModality: Modality,
            bindingContext: BindingContext
    ): Modality? {
        /* 現在の Modality が FINAI 以外の場合はプラグインの処理をしません。
         * Modality は以下のものがあります。
         *   Modality.FINAL
         *   Modality.SEALED
         *   Modality.OPEN
         *   Modality.ABSTRACT */
        if (currentModality != Modality.FINAL) {
            return null
        }

        /* ここは理解していません。コンパイラーを追いかけないと分からなそうです・・・。 */
        val descriptor = declaration as? ClassDescriptor ?: containingDeclaration ?: return null

        /* コンストラクターで指定されたアノテーションが指定されている場合 */
        if (descriptor.hasSpecialAnnotation(modifierListOwner)) {
            /* final 修飾子が付いている場合は final のまま、それ以外の場合は open に変更します。 */
            return if (modifierListOwner.hasModifier(KtTokens.FINAL_KEYWORD))
                Modality.FINAL // Explicit final
            else
                Modality.OPEN
        }

        /* null を返すと、変更なし。 */
        return null
    }
}

まとめ

ソースにコメントを付けるという簡易的な形で、コンパイラプラグインの処理について追いかけてみました。

このプラグインの他にも、plugins ディレクトリにいくつか実装があります。またプラグインの他にも、Intellij IDEA 用のプラグインのようなコードもあるので、コンパイラプラグインに興味のある方は追いかけてみてください。