🛠️

自作言語のツールチェーンインストーラを作る

2024/12/03に公開

はじめに

この記事では自作言語向けにツールチェーンインストーラを作成する方法について説明します。ツールチェーンインストーラとはRustにおける rustup のようなツールのことで、最新のツールチェーンへのアップデートや、バージョン切り替えなどの機能を提供します。

特にまだ安定していない自作言語の場合、簡単にアップデートやバージョン切り替えができることはユーザの利便性だけでなく(例えば特定のバージョンで入ったバグの調査など)自分自身の開発にも便利です。

verylup

以下では自作のハードウェア記述言語 Veryl のツールチェーンインストーラである verylup の実装に基づいて解説します。ソースコードの詳細は以下のリポジトリを参照してください。

https://github.com/veryl-lang/verylup

必要な実装

ツールチェーンインストーラに必要な実装はおおむね以下のようになります。

  • ツールチェーンのダウンロードと展開
  • ツールチェーンの実行を乗っ取る
  • 自己アップデート

ツールチェーンのダウンロードと展開

ツールチェーンをダウンロードするにはダウンロード先が必要です。ソースコードをGitHubで公開している場合はGitHubのリリース機能を使うといいでしょう。
GitHubへのリリースの詳細は説明しませんが、GitHub Actionsでビルドしてリリースするワークフローは以下にあります。

https://github.com/veryl-lang/verylup/blob/master/.github/workflows/release.yml

GitHubにリリースした場合、パッケージは以下のURLから取得できます。

https://github.com/{user}/{project}/releases/download/v{version}/{archive}

また、最新のバージョンは

https://github.com/{user}/{project}/releases/latest

というURLに対して GET すると

https://github.com/{user}/{project}/releases/tag/v{version}

にリダイレクトされるので、その末尾から取得できます。これを実装したのが以下のコードです。

pub async fn get_latest_version(project: &str) -> Result<Version> {
    let url = format!("https://github.com/veryl-lang/{project}/releases/latest");
    let resp = reqwest::get(url).await?;
    let path = resp.url().path();
    let version = path.split("/").last().unwrap();
    let version = version.strip_prefix('v').unwrap();
    let version = Version::parse(version)?;
    Ok(version)
}

ダウンロード先が分かれば一時ディレクトリにダウンロードしてアーカイブを展開し、インストール先にコピーします。インストール先としては XDG Base Directory Specification に従うのがいいと思います。Rustなら directories クレートが使えます。

ツールチェーンの実行を乗っ取る

例えば verylup では veryl +0.10.0 build のようなコマンドを実行したときに、バージョン0.10.0のVerylコンパイラを呼び出すことができます。これを実現するために

  • ツールチェーンのコマンドをインストーラコマンドへのハードリンクにする
  • インストーラは自身が呼び出された名前によって動作を切り替える

ということを行います。ソースコードとしては以下のように std::fs::hard_link を呼ぶだけです。
Verylの場合はコンパイラコマンドである veryl と Language Server である veryl-ls の両方を乗っ取りたいので2つのハードリンクを作成します。

pub const TOOLS: &[&str] = &["veryl", "veryl-ls"];

fn update_link(self_path: &Path) -> Result<()> {
    let self_path = self_path.canonicalize()?;

    for tool in TOOLS {
        info!("creating hardlink: {tool}");

        let mut tool_path = self_path.parent().unwrap().join(tool);
        if cfg!(target_os = "windows") {
            tool_path.set_extension("exe");
        }
        if tool_path.exists() {
            fs::remove_file(&tool_path)?;
            fs::hard_link(&self_path, &tool_path)?;
        } else {
            fs::hard_link(&self_path, &tool_path)?;
        }
    }

    Ok(())
}

実行されたインストーラの動作を切り替える部分の実装は以下のように std::env::args から自身が呼ばれた名前を取得して切り替えます。verylup_modeverylup として呼ばれた場合、proxy_mode がそれ以外(つまり verylveryl-ls )の場合です。

fn self_name() -> Option<String> {
    let mut args = env::args();
    let arg0 = args.next().map(PathBuf::from);
    arg0.as_ref()
        .and_then(|x| x.file_stem())
        .and_then(std::ffi::OsStr::to_str)
        .map(String::from)
}

#[tokio::main]
async fn main() -> Result<ExitCode> {
    match self_name().as_deref() {
        Some("verylup") => {
            cli::verylup_mode::main().await?;
        }
        Some(x) => {
            cli::proxy_mode::main(x).await?;
        }
        _ => (),
    }

    Ok(ExitCode::SUCCESS)
}

あとは proxy_mode 側で +0.10.0 のような引数を検出して適切なツールチェーンのバイナリを呼び出してやるだけです。

自己アップデート

ここまでの実装でツールチェーンのアップデートやバージョン切り替えはできるようになりましたが、インストーラ自体のアップデートもできると便利です。実行中のバイナリを自分自身で書き換えるのはプラットフォーム毎に適切なやり方があるので、そのあたりをカバーしたライブラリを使うといいでしょう。
verylup では self-replace クレートを使っています。

おわりに

自作言語向けにツールチェーンインストーラを作成する方法について解説しました。参考にした rustup では様々なプラットフォームに対応するためにここで取り上げていない様々なテクニックが使われています。ですが最近の Linux/macOS/Windows に対応するくらいであればこの程度の実装でも十分なことが多いです。自作言語を作られている方はぜひツールチェーンインストーラの実装もしてみてください。

Discussion