複数言語を扱うプロジェクト、ビルドはどうする

どうも。日曜プログラマのUBinKitteです。
最近、自分のために画像のブックマークをするアプリを作ったのですが、
不意に、**いくつか言語使って開発したらカッコよくね**と思い立って、Javascript、PythonとRustが混在するプロジェクトを作ってしまいました。

こうなると手動でビルドするのは面倒です。そこで、色々とタスクランナーを調べたり、ChatGPTに聞いたりしました。

※この記事での「タスクランナー」はmakeの様なビルドの自動化をするものの事を指してるいます。

## 前提

- Windows環境 + MSYS2を使用したgccとrust

この記事では、例としてこんなのをビルドしてみます。

### ディレクトリ構成

```txt
\---project
    +---c
    |       main.c
    |       
    +---dist
    \---rust
        |   .gitignore
        |   Cargo.lock
        |   Cargo.toml
        |   
        \---src
                main.rs
```

また、中身は、

```c:./c/main.c
#include <stdio.h>

int main() {
    printf("Hello world!\n");
}

```

```rs: ./rust/src/main.rs
use std::process::Command;

fn main() {
    let _ = Command::new(r".\a.exe").spawn().expect("error!");
}

```

となっています。

`a.exe`は`main.c`を`gcc`で、`rust.exe`は`main.rs`を`cargo`でそれぞれコンパイルしたものです。
で、distに詰めます。

Rustのコードが甘過ぎで実行が終わったあとにHello, world!が出てきますが、細かいことは気にしないでください。

## シェルスクリプトorバッチファイル

学習難易度が一番低いです。というか全員履修済みでは？
加えて、どの環境でも動きます。そういう所では最強じゃないかと...。

```sh
# ウチの環境ではcargoをmsysで動くようにしてないので動作未確認です。orz

mkdir ./dist
gcc ./c/main.c -o ./dist/a.exe
cargo build --release
cp ./rust/target/release/rust.exe ./dist/rust.exe
```

```bat
mkdir .\dist
gcc .\c\main.c -o .\dist\a.exe
cargo build --release --manifest-path .\rust\Cargo.toml
cp .\rust\target\release\rust.exe .\dist\rust.exe
```

### メリット

- ほとんどの人がわかる + 学習難易度が低い
- 悩んでも情報量が多い

### デメリット

- 記述が単調になりがち
- Windows環境とその他Unix環境で使い回せない -> MSYS2やCygwinなど選択肢は有る
- エラーに対してめっぽう弱い

まあ、あんまり嬉しいタスクランナーではないかもです。
ただ、小規模なプロジェクトならこれでいいでしょう。

## makefile (GNU Make)

おなじみ[GNU Make](https://www.gnu.org/software/make/)。お恥ずかしながら私が唯一使えるものです(笑)。
圧倒的な知名度と歴史があるので、解説は省きます。各々調べてください。

```makefile:makefile
CC = gcc
CFLAGS =
DIST_DIR = ./dist
C_SRC = ./c/main.c

RUST_MANIFEST = ./rust/Cargo.toml

all: $(shell (mkdir ./dist)) $(DIST_DIR)/main_c $(DIST_DIR)/main_rust

$(DIST_DIR)/main_c: $(C_SRC)
	@$(CC) $(CFLAGS) $(C_SRC) -o $(DIST_DIR)/a

$(DIST_DIR)/main_rust: $(RUST_MANIFEST)
	@cargo build --release --manifest-path $(RUST_MANIFEST)
	@cp ./rust/target/release/rust.exe $(DIST_DIR)/rust.exe

.PHONY: all

```

見慣れすぎて特に感想がありません。

### メリット

- ほとんどの人がわかる
- 悩んでも情報量が多い
- `make` と入力するだけで実行できる
- ~~ChatGPT曰く、『伝統的なビルドツール』~~

### デメリット

- 大規模なプロジェクトに使うには動作が遅め
- 移植性は少し低め

安心感強めのタスクランナーでしょうが、モダンなプロジェクト向きではないかもです。
というか`Makefile`ってビルドツールなんですっけ...。

小回りの利く点では最強だと思います。
簡単なものなら`make`だけで実行できる手軽さもいいですね。

## スクリプト言語のライブラリとか

Rubyの`Rake`や`SCons`などありますが、ここではPythonと[invoke](https://www.pyinvoke.org/)の例を紹介します。

```py:build.py
from invoke import task

@task
def build(c):
    c.run("mkdir dist")
    c.run("gcc ./c/main.c -o ./dist/a")
    c.run("cargo build --release --manifest-path ./rust/Cargo.toml")
    c.run("cp ./rust/target/release/rust.exe ./dist/rust.exe")
```

invokeは`pip`で入れてください。

```sh
py -m pip install invoke
```

また、実行するときは、
`invoke build`です。

### メリット

- 慣れ親しんだスクリプト言語の環境が使える

### デメリット

- シェル語に依存するため、対応が必須
- 引数がうまく動作しないなど、各言語の抱える悩みがある

`ChatGPT`に紹介されたので使ってみましたが、あんまり良くない気がします。
スクリプト言語が使える点は良いですが、特別な理由がなければこれを使う必要はなさそうです。

## cargo-make

[cargo-make](https://sagiegurari.github.io/cargo-make/)はRust製のタスクランナーです。Rustを想定した作りですが、他の言語で使うのもOKな感じです。

[Githubのリリースページ](https://github.com/sagiegurari/cargo-make/releases)からダウンロードしてください。

```toml:Makefile.toml
[tasks.create_dist]
command = "mkdir"
args = ["-p", "./dist"]

[tasks.build_c]
command = "gcc"
args = ["./c/main.c", "-o", "./dist/a"]

# こういう書き方もできる
[tasks.build_rust]
script_runner = "@shell"
script = '''
cargo build --release --manifest-path ./rust/Cargo.toml
cp ./rust/target/release/rust.exe ./dist/rust.exe
'''

[tasks.build_all]
dependencies = ["create_dist", "build_c", "build_rust"]
command = "echo"
args = ["Builds completed successfully!"]
```

### メリット

- `toml`で書ける
- Rustと相性が良い
- makefileのように小回りが利く
- シェル語以外にも、`Python`や`js`、`perl`まで召喚できる

### デメリット

- 情報が少ない　
- `toml`は複雑になると可読性が落ちる

Rustの`Cargo.toml`の書き心地です。あれが好きな方にはおすすめ。

あの書き心地を求めていました私でしたが、引数の指定で泣きました。
`args = ["./c/main.c", "-o", "./dist/a"]`ってなんだよ(憤慨)

また、`script_runner`という機能でPythonやシェル語など他言語が呼べます。なぜ。シェル語が呼べるので、上の引数問題も解決します。どういうことだ。

名前こそ`cargo`ですが、tomlが読めるなら案外使いやすいです。

## Taskfile (Task)

[Taskfile](https://taskfile.dev/)はGoで実装された比較的新しいタスクランナーです。
`Taskfile`は、`makefile`のように独自の書き方をせず、`yaml`を使用します。

インストールは各種パッケージマネージャーから、もしくは[Githubのリリースページ](https://github.com/go-task/task/releases)から。

```shell
# https://taskfile.dev/installation/ より一部
winget install Task.Task
brew install go-task
sudo snap install task --classic
```

そして、`Taskfile.yml`を書きます。

```yml:Taskfile.yml
version: '3'

tasks:
  build_c:
    cmds:
      - gcc ./c/main.c -o ./dist/a
    description: "Build C project"

  build_rust:
    cmds:
      - cargo build --release --manifest-path ./rust/Cargo.toml
      - cp ./rust/target/release/rust.exe ./dist/rust.exe
    description: "Build Rust project"

  build_all:
    deps: [build_c, build_rust]
    description: "Build both C and Rust projects"
```

yamlでタスクランナー系のものを書けるとは思いませんでしたね。

### メリット

- `yaml`が使える
- `makefile`のような小回りの良さ
- `makefile`より記述が簡単

### デメリット

- 大規模プロジェクトは厳しい

[Taskのホームページ](https://taskfile.dev/)には、
> Task is a task runner / build tool that aims to be simpler and easier to use than, for example, GNU Make.

と書いてあり、`GNU Make`をかなり意識していることが読み取れます。

`yaml`での記述ができて管理しやすいですが、新しい故に使いにくい部分もありそうです。

`Makefile`とDockerfileの中間みたいな感じです。`yaml`わかる方なら簡単に馴染むと思います。

## 無理やりビルド

「**他言語用のツールでビルドしよう**」という企みです。

### gradle

皆さんこ存じ[「Gradle」](https://gradle.org/)はJavaなんかのビルドシステムですね。

ただ、ふつーにタスクランナーとして使えます。

プロジェクトのルートに、`build.gradle`を作成します。
中身を、このように編集します。

```gradle:build.gradle
task buildC(type: Exec) {
    commandLine 'gcc', 'c/main.c', '-o', 'dist/a.exe'
}

task buildRust(type: Exec) {
    commandLine 'cmd', '-c' 'cargo build --release --manifest-path rust/Cargo.toml'
}

stask copyRust(type: Copy) {
    from file('rust/target/release/rust.exe')
    into file('dist')
}

task buildAll {
    dependsOn buildC, buildRust, copyRust
}

task createDistDir {
    doLast {
        mkdir 'dist'
    }
}

buildAll.dependsOn createDistDir
```

依存関係の書いてないgradleファイルを見るのは初めてかもしれません。

`gradle buildAll`で実行できます。

`'cmd', '-c' 'cargo build --release --manifest-path rust/Cargo.toml'`のように指定するとなんとコマンドをそのまま使えるようです。

これを活かして、ちょっとこんなことをしてみました。

```gradle:build.gradle
ext {
    CC = 'gcc'
    CFLAGS = ''
    DIST_DIR = '-o ./dist'
    C_SRC = './c/main.c'
}


task buildC(type: Exec) {
    commandLine 'cmd', '-c', "${CC}' ${CFLAGS} ${C_SRC} ${DIST_DIR}"
}
```

パット見は`makefile`ですね(笑)。
task同士で依存関係も使えるので、`makefile`の代替としてアリです。

もっと言えば、VSCodeの拡張機能を使えばGUI上でtaskの確認ができるので、`makefile`よりも優秀かもしれません。

### npm scripts

皆さんご存知、主にnode.js用の[npm scripts](https://docs.npmjs.com/cli/v10/using-npm/scripts)です。

まず、プロジェクトのルートに`package.json`を作ります。そして中身を、

```json:package.json
{
  "scripts": {
    "build": "mkdir dist && gcc ./c/main.c -o ./dist/a && cargo build --release --manifest-path ./rust/Cargo.toml && cp ./rust/target/release/rust.exe ./dist/rust.exe"
  }
}
```

こうしてから、`npm run build`です。

「素直にコピーして貼れよ」って思いましたが、コピペできないCLI環境などでは使えるので、**考えられる中で最も悪い選択肢**としては有りかもしれないです。
まあシェル語っていう選択肢があるんですが...。

## その他

いろいろ見つけましたが、ウチの環境ではうまく動かなかったものたちです。

- [CMake](https://cmake.org/)
- [Bazel](https://bazel.build/)
- [Meson](https://mesonbuild.com/) と [Ninja](https://ninja-build.org/)
- [Waf](https://waf.io/)

`msvc`を使わないようにしているので、大半動きませんでした。
まあ他の方が情報上げてるからいいかな...と。

## 結論

今回の例くらいの簡単なものなら`Makefile`でいいんじゃないでしょうか？保守性を考えるなら`taskfile`な気がします。
大きくなってきたら`Bazel`や`Meson`だったりをおすすめしたいです。今回は動きませんでしたが...。

**おれはいつまでも*Makefile*をあいしているぜ！**