自作Kotlinコンパイラの作り方

はじめに

少し遅刻しましたが、これはKotlin Advent Calendar 201914日目の記事です。 少し前からkotlinc-dotnetというKotlinを.Netで動くexe形式にコンパイルするコンパイラを作っていて、kotlinのコンパイラ周りの知見が溜まってきたので、この記事では自作Kotlinコンパイラの作り方を解説します

自作Kotlinコンパイラとは?

自作Kotlinコンパイラとはその名の通り、自作したkotlinコンパイラの事です。 現在Kotlin公式のコンパイラではJVM・JS・Native以外の環境でKotlinを動かすことはできませんが、独自でコンパイラを作れば任意の環境でKotlinを動かすことができます。 この記事では簡単なKotlinのプログラムを.NETで動くexe形式に変換するコンパイラのざっくり解説をします。

自作Kotlinコンパイラの作り方

それでは自作Kotlinコンパイラの作り方をざっくり解説します。 いきなり完璧なコンパイラ作るのは無理なのでコンパイラの仕様をめちゃくちゃ簡単にします。 今回作るコンパイラの仕様です。

  • コンパイルできるのはmain関数だけ
  • main関数ではprintln関数しか呼び出せない
  • Kotlinのプログラムを.NETで動くexe形式に変換する

つまり以下のようなプログラムを.NETで動くexe形式に変換するコンパイラを作ります。

fun main() {
    println("hello world.")
}

今回作るコンパイラのソースコードはGitHubにあります。

検証環境

  • mono 6.4.0.198
  • kotlin 1.3.61
  • gradle 5.6

step1 プロジェクトを作成

step1での変更点はこちら

まずはプロジェクトを作成します。プロジェクトを作成する際にgradleの依存関係にorg.jetbrains.kotlin kotlin-compilerを追加します。 org.jetbrains.kotlin kotlin-compilerはKotlin/JVM, Kotlin/JS, Kotlin/Nativeのコンパイラが共通で使っているパッケージでAstパーサーやKotlinの中間表現(IR)などが含まれています。

KotlinはC言語に比べると言語仕様が複雑なので、パーサーを一から書こうとすると骨が折れます。なので今回はorg.jetbrains.kotlin kotlin-compilerを使う事にします。

//build.gradle.kts
plugins {
    kotlin("jvm") version "1.3.61"
    id("com.github.johnrengelman.shadow") version "5.0.0"
    application
}

group = "com.github.youta1119"
version = "1.0-SNAPSHOT"
val mainClass = "com.github.youta1119.kotlin.MainKt"
repositories {
    jcenter()
    mavenCentral()
}

dependencies {
    implementation(kotlin("stdlib-jdk8"))
    implementation(kotlin("compiler"))//追加
}

step2 コンパイラのCLI部分を作成

step2での変更点はこちら

次にコンパイラのCLI部分を作成します。 まず、コンパイラのコマンドライン引数を定義をします。 CommonCompilerArgumentsを継承したK2DotNetCompilerArgumentsというクラスを作成します。 CommonCompilerArgumentsを継承-versionなどの引数を使えるようにするためです。

//K2DotNetCompilerArguments.kt
package com.github.youta1119.kotlin.cli

import org.jetbrains.kotlin.cli.common.arguments.Argument
import org.jetbrains.kotlin.cli.common.arguments.CommonCompilerArguments
//コマンドライン引数の定義
class K2DotNetCompilerArguments: CommonCompilerArguments() {
    // @Argumentアノテーションをフィールドにつけるとコマンドライン引数をパースした結果がフィールドに入る
    @Argument(value = "-output", shortName = "-o", valueDescription = "<name>", description = "Output name")
    var outputName: String? = null
}

次にCLI部を作成します。CLICompilerというクラスを継承したK2DotNetCompilerというクラスを作ります。 CLICompilerはコマンドライン引数のパースをいい感じにやってくれるクラスです。

//K2DotNetCompiler.kt
package com.github.youta1119.kotlin.cli

import com.github.youta1119.kotlin.compiler.DotNetConfigurationKeys
import com.intellij.openapi.Disposable
import org.jetbrains.kotlin.cli.common.CLICompiler
import org.jetbrains.kotlin.cli.common.CommonCompilerPerformanceManager
import org.jetbrains.kotlin.cli.common.ExitCode
import org.jetbrains.kotlin.cli.common.config.addKotlinSourceRoot
import org.jetbrains.kotlin.config.CompilerConfiguration
import org.jetbrains.kotlin.config.Services
import org.jetbrains.kotlin.metadata.deserialization.BinaryVersion
import org.jetbrains.kotlin.utils.KotlinPaths

class K2DotNetCompiler : CLICompiler<K2DotNetCompilerArguments>() {

    override val performanceManager: CommonCompilerPerformanceManager by lazy {
        K2DotNetCompilerPerformanceManager()
    }
    //コンパイラの処理を書くところ
    override fun doExecute(
        arguments: K2DotNetCompilerArguments,
        configuration: CompilerConfiguration,
        rootDisposable: Disposable,
        paths: KotlinPaths?
    ): ExitCode {
        return ExitCode.OK
    }
    //Platform特有の設定はここでやる
    override fun setupPlatformSpecificArgumentsAndServices(
        configuration: CompilerConfiguration,
        arguments: K2DotNetCompilerArguments,
        services: Services
    ) {
        val commonSources = arguments.commonSources?.toSet().orEmpty()
        arguments.freeArgs.forEach {
            configuration.addKotlinSourceRoot(it, it in commonSources)
        }
        with(DotNetConfigurationKeys) {
            with(configuration) {
                arguments.outputName?.let { put(OUTPUT_NAME, it) }
            }
        }
    }

    override fun createArguments(): K2DotNetCompilerArguments =
        K2DotNetCompilerArguments()

    override fun createMetadataVersion(versionArray: IntArray): BinaryVersion =
        K2DotNetMetadataVersion()

    override fun executableScriptFileName(): String = "kotlinc-dotnet"
    //よくわからないけど必要
    class K2DotNetCompilerPerformanceManager : CommonCompilerPerformanceManager("Kotlin to .Net Compiler")
    //よくわからないけど必要
    class K2DotNetMetadataVersion(vararg numbers: Int) : BinaryVersion(*numbers) {
        override fun isCompatible(): Boolean = false
    }

    companion object {
        @JvmStatic
        fun main(args: Array<String>) {
            doMain(K2DotNetCompiler(), args)
        }
    }
}

これでコンパイラのCLI部分は完成です。今の段階で-helpオプションをつけて自作コンパイラを動かしてみるとこんな感じになります。 ちゃんとヘルプが表示されますね。

step3. コンパイラのベース部分作成

step3での変更点はこちら

CLIができたので、次にコンパイラのベース部分を作っていきます。 まずCommonBackendContextを継承したDotNetBackendContextを作成します。DotNetBackendContextはコンパイル中の状態を保持するために使われるものです。

// DotNetBackendContext.kt
package com.github.youta1119.kotlin.compiler


import com.github.youta1119.kotlin.config.DotNetConfigurationKeys
import org.jetbrains.kotlin.backend.common.CommonBackendContext
import org.jetbrains.kotlin.backend.common.ir.DeclarationFactory
import org.jetbrains.kotlin.backend.common.ir.Ir
import org.jetbrains.kotlin.backend.common.ir.SharedVariablesManager
import org.jetbrains.kotlin.cli.common.CLIConfigurationKeys
import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSeverity
import org.jetbrains.kotlin.cli.common.messages.MessageCollector
import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment
import org.jetbrains.kotlin.config.CompilerConfiguration
import org.jetbrains.kotlin.descriptors.ModuleDescriptor
import org.jetbrains.kotlin.ir.IrElement
import org.jetbrains.kotlin.ir.declarations.IrFile
import org.jetbrains.kotlin.ir.descriptors.IrBuiltIns
import org.jetbrains.kotlin.name.FqName
import org.jetbrains.kotlin.resolve.BindingContext

class DotNetBackendContext(val environment: KotlinCoreEnvironment, override val configuration: CompilerConfiguration) :
    CommonBackendContext {
    lateinit var moduleDescriptor: ModuleDescriptor
    lateinit var bindingContext: BindingContext

    val phaseConfig = configuration.get(CLIConfigurationKeys.PHASE_CONFIG)!!

    val outputName = configuration.get(DotNetConfigurationKeys.OUTPUT_NAME) ?: DEFAULT_OUTPUT_NAME

    override val builtIns: DotNetBuiltIns by lazy {
        moduleDescriptor.builtIns as DotNetBuiltIns
    }

    override val irBuiltIns: IrBuiltIns
        get() = TODO("not implemented")

    override val sharedVariablesManager: SharedVariablesManager
        get() = TODO("not implemented")

    override val declarationFactory: DeclarationFactory
        get() = TODO("not implemented")
    override var inVerbosePhase: Boolean = false

    override val internalPackageFqn: FqName
        get() = TODO("not implemented")

    override val ir: Ir<CommonBackendContext>
        get() = TODO("not implemented")

    override fun log(message: () -> String) {
        if (inVerbosePhase) {
            println(message())
        }
    }

    override fun report(element: IrElement?, irFile: IrFile?, message: String, isError: Boolean) {
        this.messageCollector.report(
            if (isError) CompilerMessageSeverity.ERROR else CompilerMessageSeverity.WARNING,
            message, null
        )
    }

    private val messageCollector: MessageCollector
        get() = configuration.getNotNull(CLIConfigurationKeys.MESSAGE_COLLECTOR_KEY)

    companion object {
        private const val DEFAULT_OUTPUT_NAME = "program"
    }
}

次にコンパイラのフェーズを作っていきます。公式のKotlinコンパイラはフェーズという単位で処理を分割する設計になっているのでそれを真似します。とりあえず何もしないNoOpをいうフェーズを作ってみます。

//CompilerPhases.kt
package com.github.youta1119.kotlin.compiler

import org.jetbrains.kotlin.backend.common.phaser.*
import org.jetbrains.kotlin.cli.common.messages.AnalyzerWithCompilerReport
import org.jetbrains.kotlin.config.languageVersionSettings


internal fun createUnitPhase(
    name: String,
    description: String,
    prerequisite: Set<AnyNamedPhase> = emptySet(),
    op: DotNetBackendContext.() -> Unit
) = namedOpUnitPhase(name, description, prerequisite, op)

// 何もしないフェーズ
internal val noOpPhase = createUnitPhase(
    op = {
       println("no op")
    },
    name = "NoOp",
    description = "no-op"
)
//コンパイルに使うの全てのフェーズをまとめたフェーズ
val toplevelPhase: CompilerPhase<DotNetBackendContext, Unit, Unit> = namedUnitPhase(
    name = "Compiler",
    description = "The whole compilation process",
    lower = noOpPhase
)

フェーズを作成したらtoplevelPhaseを呼び出すcompileToDotNetByteCode関数を作成します。 またK2DotNetCompilerdoExecuteメソッドを修正してcompileToDotNetByteCodeを呼び出すようにします。

//compiler.kt
package com.github.youta1119.kotlin.compiler

import org.jetbrains.kotlin.backend.common.phaser.invokeToplevel
import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment
import org.jetbrains.kotlin.config.CompilerConfiguration


fun compileToDotNetByteCode(environment: KotlinCoreEnvironment, configuration: CompilerConfiguration) {
    val context = DotNetBackendContext(environment, configuration)
    toplevelPhase.invokeToplevel(context.phaseConfig, context, Unit)
}


// K2DotNetCompiler.kt
package com.github.youta1119.kotlin.cli

import com.github.youta1119.kotlin.compiler.compileToDotNetByteCode
import com.github.youta1119.kotlin.compiler.toplevelPhase
import com.github.youta1119.kotlin.config.DotNetConfigurationKeys
import com.intellij.openapi.Disposable
import org.jetbrains.kotlin.cli.common.*
import org.jetbrains.kotlin.cli.common.config.addKotlinSourceRoot
import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSeverity
import org.jetbrains.kotlin.cli.common.messages.MessageCollector
import org.jetbrains.kotlin.cli.common.messages.MessageUtil
import org.jetbrains.kotlin.cli.common.messages.OutputMessageUtil
import org.jetbrains.kotlin.cli.jvm.compiler.EnvironmentConfigFiles
import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment
import org.jetbrains.kotlin.codegen.CompilationException
import org.jetbrains.kotlin.config.CompilerConfiguration
import org.jetbrains.kotlin.config.Services
import org.jetbrains.kotlin.metadata.deserialization.BinaryVersion
import org.jetbrains.kotlin.utils.KotlinPaths

class K2DotNetCompiler : CLICompiler<K2DotNetCompilerArguments>() {

    /*中略*/
    //修正
    override fun doExecute(
        arguments: K2DotNetCompilerArguments,
        configuration: CompilerConfiguration,
        rootDisposable: Disposable,
        paths: KotlinPaths?
    ): ExitCode {
        // KotlinCoreEnvironment生成. コンパイラに渡されたソースファイルのパス情報とかを保持してるっぽい
        val environment = KotlinCoreEnvironment.createForProduction(
            rootDisposable,
            configuration,
            EnvironmentConfigFiles.NATIVE_CONFIG_FILES
        )
        val messageCollector = configuration.get(CLIConfigurationKeys.MESSAGE_COLLECTOR_KEY) ?: MessageCollector.NONE
        // コンパイラのフェーズの設定
        configuration.put(
            CLIConfigurationKeys.PHASE_CONFIG,
            createPhaseConfig(toplevelPhase, arguments, messageCollector)
        )
        if (environment.getSourceFiles().isEmpty()) {
            if (arguments.version) return ExitCode.OK

            messageCollector.report(CompilerMessageSeverity.ERROR, "No source files")
            return ExitCode.COMPILATION_ERROR
        }
        return try {
            //コンパイルの実行
            compileToDotNetByteCode(environment, configuration)
            ExitCode.OK
        } catch (e: CompilationException) {
            messageCollector.report(
                CompilerMessageSeverity.EXCEPTION,
                OutputMessageUtil.renderException(e),
                MessageUtil.psiElementToMessageLocation(e.element)
            )
            ExitCode.INTERNAL_ERROR
        }
    }
    /*中略*/
}

これでコンパイラのベース部分は完成です。 今の段階で-Xlist-phasesオプションをつけて自作コンパイラを動かしてみるとこんな感じになります。-Xlist-phasesはコンパイラの全フェーズを表示する開発者向けのオプションです。 ちゃんと今回作成したNoOp Phaseが表示されますね。

step4. Astを作成する

step4での変更点はこちら

コンパイラのベース部分ができたので、ソースコードからAstに生成する部分を作っていきます。 Astとはコードをパースした抽象構文木のことでKotlinの場合はPsiという形式で表現されています。 まずソースコードをパースしてAstに生成するTopDownAnalyzerFacadeForDotNetを作ります。

// TopDownAnalyzerFacadeForDotNet.kt
package com.github.youta1119.kotlin.compiler

import com.intellij.openapi.project.Project
import org.jetbrains.kotlin.analyzer.AnalysisResult
import org.jetbrains.kotlin.cli.common.config.KotlinSourceRoot
import org.jetbrains.kotlin.cli.jvm.compiler.createSourceFilesFromSourceRoots
import org.jetbrains.kotlin.config.*
import org.jetbrains.kotlin.context.ContextForNewModule
import org.jetbrains.kotlin.context.ModuleContext
import org.jetbrains.kotlin.context.ProjectContext
import org.jetbrains.kotlin.descriptors.impl.ModuleDescriptorImpl
import org.jetbrains.kotlin.name.Name
import org.jetbrains.kotlin.psi.KtFile
import org.jetbrains.kotlin.resolve.*
import org.jetbrains.kotlin.resolve.lazy.declarations.FileBasedDeclarationProviderFactory
import java.nio.file.Files
import java.nio.file.Paths
import kotlin.streams.toList

object TopDownAnalyzerFacadeForDotNet {

    // ソースコードからAstを生成する
    @JvmStatic
    fun analyzeFiles(
        project: Project,
        configuration: CompilerConfiguration,
        files: Collection<KtFile>

    ): AnalysisResult {

        val projectContext = ProjectContext(project, "TopDownAnalyzer for DotNet")
        val builtIns = DotNetBuiltIns(projectContext.storageManager)
        // <main>という名前のモジュールを作成
        // モジュールという単位でソースコードをパースしていく
        val context = ContextForNewModule(
            projectContext,
            Name.special("<main>"),
            builtIns, null
        )
        val module = context.module
        builtIns.builtInsModule = module
        context.setDependencies(module)
        return analyzeFilesWithGivenTrace(files, BindingTraceContext(), context,configuration)
    }
    // ソースコードからAstを変換する
    private fun analyzeFilesWithGivenTrace(
        files: Collection<KtFile>,
        trace: BindingTrace,
        moduleContext: ModuleContext,
        configuration: CompilerConfiguration
    ): AnalysisResult {

        // we print out each file we compile for now
        files.forEach { println(it) }
        // パーサインスタンス生成
        val analyzerForKonan = createTopDownAnalyzerForDotNet(
            moduleContext, trace,
            FileBasedDeclarationProviderFactory(moduleContext.storageManager, files),
            configuration.get(CommonConfigurationKeys.LANGUAGE_VERSION_SETTINGS)!!
        )
        // ソースコード->Astへの変換する
        analyzerForKonan.analyzeDeclarations(TopDownAnalysisMode.TopLevelDeclarations, files)
        return AnalysisResult.success(trace.bindingContext, moduleContext.module)
    }
}


// DotNetPlatform.kt
//よくわからないけど必要っぽい
package com.github.youta1119.kotlin.compiler

import org.jetbrains.kotlin.builtins.KotlinBuiltIns
import org.jetbrains.kotlin.platform.TargetPlatform
import org.jetbrains.kotlin.platform.konan.KonanPlatform
import org.jetbrains.kotlin.platform.konan.KonanPlatforms
import org.jetbrains.kotlin.storage.StorageManager

class DotNetBuiltIns(storageManager: StorageManager) : KotlinBuiltIns(storageManager)

@Suppress("DEPRECATION_ERROR")
object DotNetPlatform {

    private object DefaultSimpleDotNetPlatform : KonanPlatform()
    @Deprecated(
        message = "Should be accessed only by compatibility layer, other clients should use 'defaultDotNetPlatform'",
        level = DeprecationLevel.ERROR
    )
    object CompatKonanPlatform : TargetPlatform(setOf(DefaultSimpleDotNetPlatform)),
        org.jetbrains.kotlin.resolve.TargetPlatform {
        override val platformName: String
            get() = "DotNet"
    }

    val defaultDotNetPlatform: TargetPlatform
        get() = KonanPlatforms.CompatKonanPlatform
}


// DotNetPlatformAnalyzerServices.kt
//これもよくわからないけど必要っぽい
package com.github.youta1119.kotlin.compiler

import org.jetbrains.kotlin.container.StorageComponentContainer
import org.jetbrains.kotlin.resolve.*
import org.jetbrains.kotlin.resolve.checkers.ExpectedActualDeclarationChecker
import org.jetbrains.kotlin.resolve.jvm.checkers.SuperCallWithDefaultArgumentsChecker
import org.jetbrains.kotlin.storage.StorageManager


object DotNetPlatformConfigurator : PlatformConfiguratorBase(
    additionalDeclarationCheckers = listOf(
        ExpectedActualDeclarationChecker(
            ModuleStructureOracle.SingleModule,
            emptyList()
        )
    ),
    additionalCallCheckers = listOf(SuperCallWithDefaultArgumentsChecker())
) {
    override fun configureModuleComponents(container: StorageComponentContainer) {
    }
}

object DotNetPlatformAnalyzerServices : PlatformDependentAnalyzerServices() {
    override val platformConfigurator: PlatformConfigurator =
        DotNetPlatformConfigurator

    override fun computePlatformSpecificDefaultImports(
        storageManager: StorageManager,
        result: MutableList<ImportPath>
    ) {
        //no-op
    }
}

//injection.kt
package com.github.youta1119.kotlin.compiler

import org.jetbrains.kotlin.config.LanguageVersionSettings
import org.jetbrains.kotlin.container.*
import org.jetbrains.kotlin.context.ModuleContext
import org.jetbrains.kotlin.descriptors.impl.ModuleDescriptorImpl
import org.jetbrains.kotlin.frontend.di.configureModule
import org.jetbrains.kotlin.frontend.di.configureStandardResolveComponents
import org.jetbrains.kotlin.incremental.components.LookupTracker
import org.jetbrains.kotlin.resolve.*
import org.jetbrains.kotlin.resolve.lazy.FileScopeProviderImpl
import org.jetbrains.kotlin.resolve.lazy.KotlinCodeAnalyzer
import org.jetbrains.kotlin.resolve.lazy.ResolveSession
import org.jetbrains.kotlin.resolve.lazy.declarations.DeclarationProviderFactory

// ソースコードのパーサインスタンス生成のためにDI設定
// Kotlin/Nativeのコンパイラの実装を参考にしながら設定したらなんか動いた
fun createTopDownAnalyzerForDotNet(
    moduleContext: ModuleContext,
    bindingTrace: BindingTrace,
    declarationProviderFactory: DeclarationProviderFactory,
    languageVersionSettings: LanguageVersionSettings
): LazyTopDownAnalyzer {
    val storageComponentContainer = createContainer("TopDownAnalyzerForDotNet", DotNetPlatformAnalyzerServices) {
        configureModule(
            moduleContext,
            DotNetPlatform.defaultDotNetPlatform,
            DotNetPlatformAnalyzerServices,
            bindingTrace,
            languageVersionSettings
        )
        configureStandardResolveComponents()
        useInstance(declarationProviderFactory)
        useImpl<FileScopeProviderImpl>()

        CompilerEnvironment.configure(this)

        useInstance(LookupTracker.DO_NOTHING)
       //useInstance(languageVersionSettings)
    }.apply {
        get<ModuleDescriptorImpl>().initialize(get<KotlinCodeAnalyzer>().packageFragmentProvider)
    }
    return storageComponentContainer.get()
}

次にソースコードからAstを生成するFrontendフェーズを作成します。

internal val frontendPhase = createUnitPhase(
    op = {
        val environment = environment
        val analyzerWithCompilerReport = AnalyzerWithCompilerReport(
            messageCollector,
            environment.configuration.languageVersionSettings
        )
        // Astの生成
        analyzerWithCompilerReport.analyzeAndReport(environment.getSourceFiles()) {
            TopDownAnalyzerFacadeForDotNet.analyzeFiles(
                environment.project,
                configuration,
                environment.getSourceFiles()
            )
        }
        // ソースコードに構文エラーやimportのエラーがあった場合はここでエラーになる
        if (analyzerWithCompilerReport.hasErrors()) {
            throw DotNetCompilationException()
        }
        //Astの情報が入ってる
        moduleDescriptor = analyzerWithCompilerReport.analysisResult.moduleDescriptor
        bindingContext = analyzerWithCompilerReport.analysisResult.bindingContext
    },
    name = "Frontend",
    description = "Frontend builds Ast"
)

これでいったんstep4は完了です。

step5. 標準ライブラリの定義

step5での変更点はこちら

step4の状態で以下のようなソースコードをコンパイルしようとするとコンパイルエラーになってしまいます。

fun main() {
    println("hello")
}

コンパイルエラーになるのは標準ライブラリが存在しないからです。 なので、標準ライブラリを実装してやる必要があります。 ただ、コンパイルエラーを消すだけなら標準ライブラリをすべて実装する必要はなく、クラス定義だけあればとりあえず動きます。toStringみたいな実装しないといけないメソッドにはexternal修飾子をつけておけば怒られなくなるのでとりあえずつけておきます。

//Array.kt
package kotlin
// ArrayというクラスさえあればOK
public final class Array<T> {
} 
// Any.kt
package kotlin

public open class Any {
    // external修飾子をつけると実装してなくても怒られない
    external public open operator fun equals(other: Any?): Boolean
    //external public open fun hashCode(): Int
    external open fun toString(): String
} 

標準ライブラリの定義が終わったらTopDownAnalyzerFacadeForDotNetでAstを生成する際に標準ライブラリも一緒に含めるように修正します。

object TopDownAnalyzerFacadeForDotNet {

    // ソースコードからAstを生成する
    @JvmStatic
    fun analyzeFiles(
        project: Project,
        configuration: CompilerConfiguration,
        files: Collection<KtFile>

    ): AnalysisResult {

        val projectContext = ProjectContext(project, "TopDownAnalyzer for DotNet")
        val builtIns = DotNetBuiltIns(projectContext.storageManager)
        // <main>という名前のモジュールを作成
        // モジュールという単位でソースコードをパースしていく
        val context = ContextForNewModule(
            projectContext,
            Name.special("<main>"),
            builtIns, null
        )
        val module = context.module
        builtIns.builtInsModule = module
        //修正
        context.setDependencies(listOf(module) + loadStdlibModules(projectContext, configuration))
        return analyzeFilesWithGivenTrace(files, BindingTraceContext(), context,configuration)
    }

    /*中略*/
    //追加
    //標準ライブラリの読み込み
    private fun loadStdlibModules(
        projectContext: ProjectContext,
        configuration: CompilerConfiguration
    ): ModuleDescriptorImpl {
        val stdlibFiles = Files.walk(Paths.get(Distribution.stdlibDir))
            .filter { it.toFile().extension == "kt" }
            .map { KotlinSourceRoot(it.toString(), false) }.toList()
        val ktFiles = createSourceFilesFromSourceRoots(configuration, projectContext.project, stdlibFiles)
        val builtIns = DotNetBuiltIns(projectContext.storageManager)
        // <stdlib>モジュールとして標準ライブラリを読み込み
        val context = ContextForNewModule(
            projectContext,
            STDLIB_MODULE_NAME,
            builtIns, null
        )
        val module = context.module
        builtIns.builtInsModule = module
        context.setDependencies(module)
        val analyzerForKonan = createTopDownAnalyzerForDotNet(
            context, BindingTraceContext(),
            FileBasedDeclarationProviderFactory(context.storageManager, ktFiles),
            configuration.get(CommonConfigurationKeys.LANGUAGE_VERSION_SETTINGS)!!
        )

        analyzerForKonan.analyzeDeclarations(TopDownAnalysisMode.TopLevelDeclarations, ktFiles)
        return context.module

    }

    private val STDLIB_MODULE_NAME = Name.special("<stdlib>")
}

step6. AstとKotlin IRに変換する

step6での変更点はこちら

ソースコードをパースしてAst(Psi)に変換することができたので、次はAstをIR(中間表現)に変換するPsiToIrフェーズを作っていきます。ついでに変換したIRを見るためにDumpIrフェーズを作ります。

Astには=,-,+,/などの余分な構文情報を含んでいるため、余分な構文情報を取り除いて抽象度を上げるため、AstをIR(中間表現)に変換します。IR(中間表現)はASTから余分な構文情報を取り除いて抽象度を上げたものです。

//CompilerPhases.kt
// PsiをIRに変換するフェーズ
internal val psiToIrPhase = createUnitPhase(
    op = {
        // Psi2IrTranslatorを使うとPsiとIRに変換できるっぽい
        // 動いているが、Kotlinコンパイラのソースをコピペしたのでよくわからない
        val translator = Psi2IrTranslator(environment.configuration.languageVersionSettings, Psi2IrConfiguration(false))
        val symbolTable = SymbolTable()
        val generatorContext = translator.createGeneratorContext(
            moduleDescriptor,
            bindingContext,
            symbolTable,
            GeneratorExtensions()
        )
        // PsiをIRに変換
        val module = translator.generateModuleFragment(generatorContext, environment.getSourceFiles())
        irModule = module
    },
    name = "Psi2Ir",
    description = "Psi to IR conversion"
)

// IRをdumpするフェーズ
internal val dumpIrPhase = createUnitPhase(
    op = {
       println(irModule.dump())
    },
    name = "DumpIr",
    description = "dump ir"
)

これでAstとKotlin IRに変換する部分は完成です。この状態で自作コンパイラを実行すると以下のような感じで変換されてIRが表示されます。

step7. Kotlin IRを.Net ByteCodeに変換する

step7での変更点はこちら

IRを生成することができたのでIRの情報を使って.Netアセンブリを生成するIrToDotNetAsmフェーズと.Netアセンブリとexeに変換するGenerateByteCodeフェーズを作っていきます。

internal val irToCILPhase = createUnitPhase(
    op = {
        // IRの情報を使って.Netアセンブリ生成
        val pw = File(tempFileName).printWriter()
        pw.println(".assembly extern mscorlib {}")
        pw.println(".assembly main {}")
        irModule.acceptChildrenVoid(object : IrElementVisitorVoid {
            override fun visitElement(element: IrElement) {
                element.acceptChildrenVoid(this)
            }

            override fun visitFunction(declaration: IrFunction) {
                assert(declaration.name.asString() == "main") {
                    "this is toy compiler. only main function can be compiled."
                }
                pw.println(".method static void Main() cil managed {")
                pw.println(".entrypoint")
                val body = declaration.body!! as IrBlockBody
                body.statements.forEach { statement ->
                    val callExpr = statement as IrCall
                    val calleeFunctionExpr = callExpr.symbol.owner.target
                    assert(calleeFunctionExpr.fqNameForIrSerialization.asString() == "kotlin.io.println") {
                        "this is toy compiler. only kotlin.io.println function can be called."
                    }


                    val args = (callExpr as IrMemberAccessExpression).getArguments()
                    args.forEach { (_, expr) ->
                        if (expr is IrConst<*>) {
                            pw.println("ldstr \"${expr.value}\"")
                        }
                    }
                    //println関数の呼び出しを.NetのSystem.Console::WriteLine関数呼び出しに変換
                    pw.println("call void [mscorlib]System.Console::WriteLine(string)")
                }
                pw.println("ret")
                pw.println("}")
            }
        })
        pw.close()
    },
    name = "IrToDotNetAsm",
    description = "IR to .Net assembly conversion"
)

internal val generateBytecodePhase = createUnitPhase(
    op = {
        // ilasmコマンドを実行してアセンブリをexeに変換
        val command = listOf("ilasm") + listOf("/output:${outputFileName}", tempFileName)
        val builder = ProcessBuilder(command)

        builder.redirectOutput(ProcessBuilder.Redirect.INHERIT)
        builder.redirectInput(ProcessBuilder.Redirect.INHERIT)
        builder.redirectError(ProcessBuilder.Redirect.INHERIT)

        val process = builder.start()
        val exitCode = process.waitFor()

        if (exitCode != 0) {
            throw DotNetCompilationException("ilasm command returned non-zero exit code: $exitCode.")
        }
    },
    name = "GenerateByteCode",
    description = "Generate .Net Bytecode from .Net assembly"
)

これでKotlinのプログラムを.Netのexeに変換するコンパイラの完成です。

.Netアセンブリの解説はまた今度追記します。

終わりに

Kotlinではソースコードのパーサーはライブラリ形式で配布されているため比較的少ないコード量でコンパイラを作ることができます。ちなみにこのコンパイラのコード量は570行でした しかし、Kotlinコンパイラを自作したい物好きな人はあまり居ないのかKotlinコンパイラに関するドキュメントは存在せず、色々ハマったのでこの記事を書きました。

理論上は今回解説したやり方で、コンパイラを書けばどの環境でもKotlinを動かすことができます。 この記事を読んで自作Kotlinコンパイラに興味をもってくださった方がいれば幸いです。

kotlin 

See also