Sourceしても副作用のないシェルスクリプトのテンプレート

# Sourceしても副作用のないシェルスクリプト

## ファイル置き場
(2019 Aug.追記) 遅ればせながらGitHub, GitLabに慣れるため、それぞれにプロジェクトを作って置いてみましたよ。(2019 Sep. 追記 レポジトリを作り直しました。)
- https://github.com/nanigashi-uji/bash_script_skeleton.git
- https://gitlab.com/nanigashi_uji/bash_script_skeleton.git

## 修正 / 機能追加 v0.0.3 ~ v0.0.5
(2023 Jul. 追記) 
- `source`した場合の自分自身の関数定義を元に戻すコードを追加しました。ただし、`local`でないシェル変数を使っているので、複数回`source`された場合は完全に元に戻せない可能性があります。(「副作用のない」を謳っているのですが......orz.)
- ファイルの最後に自由記述のコメントが追加できるように、明示的に実行を止めるコードを追加しました。**直接実行される場合には`exit`、`source`される場合には`return`と使い分ける必要がある**のを忘れないようにするためにテンプレートに残しておくことにしました。たとえば、スクリプトの後半に読み込むデータを埋め込んでおくような場合に有用です。
- 変数評価でQuoteされていなかったところをQuoteしました。

## ちょっと修正
(2021 Oct. 追記) バグフィックス。詳細は最後に。

## 動機/経緯

ひょんなことからとある作業をシェルスクリプトで実装することにした。同じような作業をいくつかのシェルスクリプトで使うので、別のファイルに関数定義を書いて`source`することにしたのだが、関数内で関数外で定義済みのものを上書きしてしまう副作用は避けたい。変数に関しては、

  * `local`をつけて定義
  * 環境変数は`export`は使わず、コマンド実行時に`env`で設定して実行

で回避できる。ただし、関数定義やシグナルハンドラに関してはあまり便利な方法は見つけられなかった。名前の重複を避けるように気をつけて毎回名前を変えるという手はあるが、その注意力は別に使いたい。とはいえ、毎回同じような手順を実装するのも手間なので、なるべく汎用的なコードのスケルトンを用意することにした。`source`して関数として実行するのと、単独でスクリプト実行するのを両立できるようにしたかったので、やむなく`bash`拡張を使用している。

## スケルトン

下記スケルトンでは、

1. `func_skeleton`という関数名(2箇所)を、所望のものに変える。
1. ヘルプメッセージをアップデートする。
1. 引数処理をアップデートする。
1. 関数本体を実装する。
1. (必要に応じて)終了処理を修正
1. (必要に応じて)シグナルハンドラを修正

という作業手順で使用することを想定している。機能としては、

  * 単独実行しても`source`して関数実行することも可能に
  * 変数定義は`local`を使う/環境変数定義が必要であれば`env`を使うこととする。
  * 関数を定義する際は、あらかじめ定義をローカル変数にバックアップしておき、終了時にリストアする。もっとも外側の関数(`func_skeleton`(をリネームしたもの))はそうしていないが、これは「他と被らないファイル名/コマンド名をつけるべし」という範疇のものであろう。
  * 割り込み処理の設定も、設定済みのものをローカル変数にバックアップしておき、終了時にリストアする。実際に割り込みがあった場合には、リストア後に同じシグナルを自分自身に送る。
  * ~~エイリアスも、設定済みのものをローカル変数にバックアップしておき、終了時にリストアするサンプルをつけた。~~（あっても害はない。）(エイリアスはスクリプト内では使えないので意味なし。コメントアウトした:2019 Sep.追記) 
  * 割り込み処理が必要になるもっとも一般的なケースである、一時ファイルの消去をサンプルとして書いておく。

という内容である。あまりBシェルは使ったことがなかったのでいろいろ調べた点の備忘録としては、「関数内で関数が定義できる」「複数のlocal変数定義を一行に書ける」「シグナルハンドラ自身でシグナル番号を知る機能はない（よってシグナル毎に処理を変えるには`trap`をシグナルごとに定義する必要がある。）」「シグナル処理はサブシェルには引き継がれない」といったことがありました。

## 修正 (2021/10)

関数の定義をバックアップしておき終了時にリストアする方法に関して問題になるのは、`declear -f`コマンドで出力される関数定義には最後の実行文の末尾にセミコロン(`;`)が付けられないが、一方で`eval`コマンドで読み込む場合には最後の実行文の前にセミコロン(`;`)をつけないとエラーとなる。最初のバージョンでは単純に関数定義の最後にセミコロンを追加していたが、これだけでは関数の中で関数を多層的に定義してる場合には、関数内の関数の末尾にセミコロンがないのでエラーとなる。これに対処するため、**場当たり的対処ではあるが**、(1)関数定義の最後には必ず`nop`(`:`)をつける、(2)`declear -f`で出力された`nop`(`:`)を探してその後にセミコロン(`;`)を追記する、ということにした。(多少強引な方法ではあるが、)

- 関数定義の最後に`nop`を追記 \\

```bash:関数定義(修正前)
    function  echo_usage () {
        ... 途中省略 ...
        return
    }
```
```bash:関数定義(修正後)
    function  echo_usage () {
        ... 途中省略 ...
        return
        :
    }
```

- 関数定義を復元する前に、関数定義の最後っぽい`nop`を探してセミコロンを追記して評価。(バックスラッシュの多重エスケープが必要)

```bash:cleanup()の中での関数定義復元(修正前)
        unset echo_usage
        test -n "${echo_usage_bk}" && eval ${echo_usage_bk%\}}" ; }"
```
```bash:cleanup()の中での関数定義復元(修正後)
        unset echo_usage
        test -n "${echo_usage_bk}" &&  { local echo_usage_bk="${echo_usage_bk%\}}"' \\; }'; eval "${echo_usage_bk//\; : \}/\; : \; \}}"  ; }
```

## コード全体は下記に。

``` bash:func_skeleton.sh
#!/bin/bash

function func_skeleton () {
    # Prepare Help Messages
    local funcstatus=0
    local echo_usage_bk=$(declare -f echo_usage)
    local cleanup_bk=$(declare -f cleanup)
    local tmpfiles=()
    local tmpdirs=()

    # local ls_sec_inifile_bk=$(alias ls_sec_inifile 2>/dev/null)
    # alias ls_sec_inifile='sed -E -n -e "s/^[[:blank:]]*\[([^]]+)\][[:blank:]]*$/\1/p" ${1}'

    function echo_usage () {
        if [ "$0" == "${BASH_SOURCE:-$0}" ]; then
            local this=$0
        else
            local this="${FUNCNAME[1]}"
        fi
        echo "[Usage] % $(basename ${this}) options"            1>&2
        echo "[Options]"                                        1>&2
        echo "           -d path   : Set destenation "          1>&2
        echo "           -h        : Show Help (this message)"  1>&2
        return
        :
    }

    local hndlrhup_bk=$(trap -p SIGHUP)
    local hndlrint_bk=$(trap -p SIGINT) 
    local hndlrquit_bk=$(trap -p SIGQUIT)
    local hndlrterm_bk=$(trap -p SIGTERM)

    trap -- 'cleanup ; kill -1  $$' SIGHUP
    trap -- 'cleanup ; kill -2  $$' SIGINT
    trap -- 'cleanup ; kill -3  $$' SIGQUIT
    trap -- 'cleanup ; kill -15 $$' SIGTERM
    
    function cleanup () {
        
        # removr temporary files and directories
        if [ ${#tmpfiles} -gt 0 ]; then
            rm -f "${tmpfiles[@]}"
        fi
        if [ ${#tmpdirs} -gt 0 ]; then
            rm -rf "${tmpdirs[@]}"
        fi

        # Restore  signal handler
        if [ -n "${hndlrhup_bk}"  ] ; then eval "${hndlrhup_bk}"  ;  else trap --  1 ; fi
        if [ -n "${hndlrint_bk}"  ] ; then eval "${hndlrint_bk}"  ;  else trap --  2 ; fi
        if [ -n "${hndlrquit_bk}" ] ; then eval "${hndlrquit_bk}" ;  else trap --  3 ; fi
        if [ -n "${hndlrterm_bk}" ] ; then eval "${hndlrterm_bk}" ;  else trap -- 15 ; fi

        # Restore alias and functions

        # unalias ls_sec_inifile
        # test -n "${ls_sec_inifile_bk}" && eval alias ${ls_sec_inifile_bk}

        unset echo_usage
        test -n "${echo_usage_bk}" &&  { local echo_usage_bk="${echo_usage_bk%\}}"' \\; }'; eval "${echo_usage_bk//\; : \}/\; : \; \}}"  ; }

        unset cleanup
        test -n "${cleanup_bk}" && { local cleanup_bk="${cleanup_bk%\}}"' \\; }'; eval "${cleanup_bk//\; : \}/\; : \; \}}"  ; }

        :
    }

    # Analyze command line options
    local OPT=""
    local OPTARG=""
    local OPTIND=""
    local dest="" 
    while getopts "d:h" OPT
    do
        case ${OPT} in
            d) local dest=${OPTARG}
               ;;
            h) echo_usage
               cleanup
               return 0
               ;;
            \?) echo_usage
                cleanup
                return 1
                ;;
        esac
    done
    shift $((OPTIND - 1))

    local scriptpath=${BASH_SOURCE:-$0}
    local scriptdir=$(dirname ${scriptpath})
    if [ "$0" == "${BASH_SOURCE:-$0}" ]; then
        local this=$(basename ${scriptpath})
    else
        local this="${FUNCNAME[0]}"
    fi

    local tmpdir0=$(mktemp -d "${this}.tmp.XXXXXX" )
    local tmpdirs=( "${tmpdirs[@]}" "${tmpdir0}" )
    local tmpfile0=$(mktemp   "${this}.tmp.XXXXXX" )
    local tmpfiles=( "${tmpfiles[@]}" "${tmpfile0}" )

    echo "------------------------------"
    echo "called as ${this}"
    echo "ARGS:" $*
    echo "------------------------------"
    echo_usage 0

    # clean up 
    cleanup
    return ${funcstatus}
    :
}

if [ "$0" == ${BASH_SOURCE:-$0} ]; then
    func_skeleton "$@"
fi
```