Shell Scriptの外部ファイル読み込みをインラインで結合する

# はじめに

## 動機

`bash` でスクリプトを書いていると、別ファイルに書いた関数を呼び出したいことがあります。その際は、下記の(1)のような記述でファイルを読み込むと思います。

```bash
. "$(dirname $0)"/../lib/functions # ... (1)
```

一方で、例えば [`rvm` のインストール](https://rvm.io/rvm/install)のように、Shell Scriptをダウンロードさせて実行することがよくあります。

```console
$ curl -sSL https://get.rvm.io | bash
```

この際、ユーザーに複数ファイルダウンロードさせるのは現実的ではありません。とは言え、別ファイルにあるコードを手動で配布ファイルにコピペすれば、メンテナンス性が下がることは間違いありません。そうではなくて、ファイルの読み込み箇所で該当ファイルの中身を展開して自動で結合すれば、コピペをせずにかつ1ファイルで配布することができそうです。

## インラインで結合？

Webページの読み込みを高速化させる手段の一つとして、外部ファイルとして読み込むCSSやJavscriptのファイルをHTML内に展開して配信するという方法があります。これをどのように呼ぶのかはわからないのでここでは「インライン結合」とでもしておきます。通常、 combine や　concatenate といった単語で表現されているように見受けられます。インライン展開と近いような気もするのですが、 "関数呼び出しのコストを削減するために展開する" といった意味で使われるようなので、違う単語を使ったほうが良いのかなと。

## やりたいこと

具体的には下記のように、コマンドラインで(1)のように記述された部分を展開して結合したファイルを生成できるようにしたいということです。

```console
$ combine-command "$SOURCE_FILE" > "$TARGET_FILE"
```

コマンドライン化しておき、gitのコミット時に自動的に結合ファイルを生成するところまでが目標です。

# 実装

## 方針

実装に際しては下記のような方針を立てました。

- 結合前のファイルでも動作する。
    - 結合後のファイルでしか動作確認できないというのはなかなか面倒です。
- 簡単な実装である。
    - コンパイラーのようなものは望んでおらず、インライン結合さえしてくれればいいです。
- ある程度、制限を受け入れる。
    - コードの記述方法に多少の制限があっても、実装を簡単にするためには受け入れます。

## `perl` でインライン結合

文字列変換と言えば `perl` ですので、 `perl` のワンライナーで実装してみます。結果的に出来上がったのが下記のコマンドです。

```console
$ perl -MPath::Class -I"$(dirname $SOURCE_FILE)" -nl -e '$_ !~/^(?:\.|source) ((\$\(dirname \$0\))?(.+))/ ? print $_ : $2 ? print file($INC[0].$3)->slurp : print file($3)->slurp' $SOURCE_FILE > $TARGET_FILE
```

行頭に `.` または `source` という記述があればファイル読み込み箇所であると判定しています。

`$(dirname $0)` という記述があった場合に "該当スクリプトの親ディレクトリのPATH" を取得したいのですが、ワンライナー中でこれを取得する方法が見つからず、ここでは `-I$(dirname $SOURCE_FILE)` という形で　`@INC` 配列の先頭に追加し、これを受け取っています。ここはちょっとトリッキーですね。

環境依存になりそうなものは入れたくなかったのですが、 "ファイルをすべて読み込んで出力する" という処理が面倒だったので `Path::Class` モジュールを利用しています。標準ライブラリなので問題無いでしょう。

もっと上手い方法があったらぜひ教えてください。

### 制限

この実装にしたことで、スクリプト側では、実行スクリプトからの相対パスを記述する際には `$(dirname $0)` 以外の書き方はできなくなりました。特に困るような制限ではないので良しとします。

# Git hooks

## `post-commit`

今回の私の用途では、結合したファイルはGitHubにホストしてもらいたいと思っているので、リポジトリにコミットします。結合ファイルを生成してコミットするのは定形作業なので自動化します。該当ファイルの変更をコミットしたときに生成してほしいので、Gitの `post-commit` フックに処理を実装します。

ここでは実際に私がリポジトリに仕込んだファイルを掲載しておきます。

```bash
#!/usr/bin/env bash

set -eu

SOURCE_FILE="share/setup/base.sh"
TARGET_FILE="bin/setup"

cd "$(git rev-parse --git-dir)"/../

# 最後のコミットのコミットログ（変更ファイルを含む）
CURRENT_LOG=$(git log -1 --name-only HEAD)
FIND_FLAG=1
while read
do
    case "$REPLY" in (\#*) continue;; esac

    # 対象ファイルのPATHが変更ログに含まれているか？
    if echo "$CURRENT_LOG" | grep -Fq "$REPLY"; then
        FIND_FLAG=0
        break
    fi
done <<EOS
$SOURCE_FILE
$(perl -nle '/^(?:\.|source) (?:.*\.\/)?(.+)$/ && print $1' $SOURCE_FILE)
EOS

if [ "$FIND_FLAG" -ne 0 ]; then
    exit 0 # true
fi

rm -f "$TARGET_FILE"
perl -MPath::Class -I"$(dirname "$SOURCE_FILE")" -nl -e '$_ !~/^(?:\.|source) ((\$\(dirname \$0\))?(.+))/ ? print $_ : $2 ? print file($INC[0].$3)->slurp : print file($3)->slurp' "$SOURCE_FILE" > "$TARGET_FILE"
git add "$TARGET_FILE"
git commit -m "generate combined file automatically"
```

関数化しておくともっと便利かもしれませんが、ご自分でどうぞ。

`perl` のワンライナーを２つも含むくらいなら、全部 `perl` で書けば良いじゃないかという意見もありますが、まあ良いじゃないですか。

他にも気になる点などありましたら、編集リクエストを送っていただけると助かります。

# 余談

## `sed` でやってみた

ファイルの内容を書き換えようと考えて、まず思いつく方法と言えば `sed` でしょう。しかし結果的に `sed` を採用しませんでした。理由は下に述べますが、単に `sed` 力が足りないということなのかもしれません。

### 挙動がよく理解できない

ファイルの中身を展開するという処理を最後まで書けませんでした。

```cosole
$ sed -e "s/^\. \(.*\)/$(cat \\1)/" "$SOURCE_FILE"
```

これでいけそうな気がするのですが、 `cat` に渡される値は `\1` なんですよね。

一方で下記のように書くと `\1` の内容は展開されます。

```cosole
$ sed -e "s/^\. \(.*\)/$(echo \\1)/" "$SOURCE_FILE"
```

よくわからないですね。

### 正規表現が貧弱

標準で使用できるPOSIX正規表現が貧弱なので、思ったような記述ができませんでした。 `-E` オプションを使うと拡張正規表現が使えるのですが、BSD版とLinux版で挙動が違うようで、環境依存になってしまうので不採用にしました。