はじめに
動機
bash
でスクリプトを書いていると、別ファイルに書いた関数を呼び出したいことがあります。その際は、下記の(1)のような記述でファイルを読み込むと思います。
. "$(dirname $0)"/../lib/functions # ... (1)
一方で、例えば rvm
のインストールのように、Shell Scriptをダウンロードさせて実行することがよくあります。
$ curl -sSL https://get.rvm.io | bash
この際、ユーザーに複数ファイルダウンロードさせるのは現実的ではありません。とは言え、別ファイルにあるコードを手動で配布ファイルにコピペすれば、メンテナンス性が下がることは間違いありません。そうではなくて、ファイルの読み込み箇所で該当ファイルの中身を展開して自動で結合すれば、コピペをせずにかつ1ファイルで配布することができそうです。
インラインで結合?
Webページの読み込みを高速化させる手段の一つとして、外部ファイルとして読み込むCSSやJavscriptのファイルをHTML内に展開して配信するという方法があります。これをどのように呼ぶのかはわからないのでここでは「インライン結合」とでもしておきます。通常、 combine や concatenate といった単語で表現されているように見受けられます。インライン展開と近いような気もするのですが、 "関数呼び出しのコストを削減するために展開する" といった意味で使われるようなので、違う単語を使ったほうが良いのかなと。
やりたいこと
具体的には下記のように、コマンドラインで(1)のように記述された部分を展開して結合したファイルを生成できるようにしたいということです。
$ combine-command "$SOURCE_FILE" > "$TARGET_FILE"
コマンドライン化しておき、gitのコミット時に自動的に結合ファイルを生成するところまでが目標です。
実装
方針
実装に際しては下記のような方針を立てました。
- 結合前のファイルでも動作する。
- 結合後のファイルでしか動作確認できないというのはなかなか面倒です。
- 簡単な実装である。
- コンパイラーのようなものは望んでおらず、インライン結合さえしてくれればいいです。
- ある程度、制限を受け入れる。
- コードの記述方法に多少の制限があっても、実装を簡単にするためには受け入れます。
perl
でインライン結合
文字列変換と言えば perl
ですので、 perl
のワンライナーで実装してみます。結果的に出来上がったのが下記のコマンドです。
$ 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
フックに処理を実装します。
ここでは実際に私がリポジトリに仕込んだファイルを掲載しておきます。
#!/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
のワンライナーを2つも含むくらいなら、全部 perl
で書けば良いじゃないかという意見もありますが、まあ良いじゃないですか。
他にも気になる点などありましたら、編集リクエストを送っていただけると助かります。
余談
sed
でやってみた
ファイルの内容を書き換えようと考えて、まず思いつく方法と言えば sed
でしょう。しかし結果的に sed
を採用しませんでした。理由は下に述べますが、単に sed
力が足りないということなのかもしれません。
挙動がよく理解できない
ファイルの中身を展開するという処理を最後まで書けませんでした。
$ sed -e "s/^\. \(.*\)/$(cat \\1)/" "$SOURCE_FILE"
これでいけそうな気がするのですが、 cat
に渡される値は \1
なんですよね。
一方で下記のように書くと \1
の内容は展開されます。
$ sed -e "s/^\. \(.*\)/$(echo \\1)/" "$SOURCE_FILE"
よくわからないですね。
正規表現が貧弱
標準で使用できるPOSIX正規表現が貧弱なので、思ったような記述ができませんでした。 -E
オプションを使うと拡張正規表現が使えるのですが、BSD版とLinux版で挙動が違うようで、環境依存になってしまうので不採用にしました。