🍣

fish shellで一気にファイルをsourceするプラグインを作ってみた

2022/02/13に公開約18,900字

モチベーション

前回の記事で、コマンドラインから google 検索をできるようにする fish 関数の開発について書きましたが、それを plugin manager であるfisherを使って plugin としてインストールできるように Github のリポジトリで開発をすすめました。

https://github.com/yo-goto/ggl.fish

プラグイン開発の最中、ターミナルから source コマンドを使ってコマンドのテストを行うことが多かったのですが、プラグインに使用する fish ファイルが増えるごとに source するのが面倒になり、一気に fish ファイルをソースさせるプラグインを作成しました。

当初は Git 管理している ggl(コマンドラインから google 検索させる自作プラグイン)のディレクトリ内部の fish file を一気に読み込ませるだけのものでしたが、GitHub で公開用に一般化するにあたり、次の機能を複数のオプションで搭載させて開発しました。

  • config directory にあるスニペットも soruce させる
  • カレントディレクトリ内の指定したディレクトリを source させる
  • テストディレクトリ(test/tests/)を source させる
  • 最近変更したファイルのみを source させる

fish function のオプション処理の基本については前の記事で紹介したので興味があればそちらを見ていただければと思います。

作成した source-fish は基本的にはプラグイン開発用のユーティリティとして使えますが、config ディレクトリ内の複数の fish file についてもインタラクティブに soruce させることができます。以下が実際に作成した fish プラグインのリポジトリになります。fisher install yo-goto/source-fish でインストールできます。

https://github.com/yo-goto/source-fish

この記事では、その開発にあたって得た知見と fish shell についての基礎知識、プラグイン開発の方法などを紹介します。

プラグイン開発方法

基本的には、プラグインマネージャーである fisher を使ったインストール・管理を想定してプラグインを開発します。

https://github.com/jorgebucaran/fisher

fisher のリポジトリにプラグインの開発方法が記載されています。基本的には以下のようにディレクトリ構造を作成して定義する関数名と同一の名前の 関数名.fish のようにファイルを作成します。

  • functions : 関数定義
  • completions : オートサジェストされるコンプリーションなどの設定
  • conf.d : グローバル変数などの設定用変数などの設定
ponyo
├── completions
│   └── ponyo.fish
├── conf.d
│   └── ponyo.fish
└── functions
    └── ponyo.fish

この 3 つのディレクトリすべてが必要ではなく、基本的には、functionscompletions を用意しておけばよいと思います。

この 3 つのディレクトリを適当な場所に作成して適宜 source しながら、テストします。または、fisher を使って、fisher install $PWD でディレクトリ内の fish files を一旦プラグインとしてインストールさせて fisher update $PWD でアップデートさえてあげることもできます。自分の場合は安定版を他のシェルで使いながら開発したいので、テスト用のシェルセッション内において繰り返し soruce させていました。

ggl のプロジェクトのディレクトリ構造は以下のような感じになります。ggl のプラグインにはそのラッパーである fin というコマンドを更に開発で入れたのでこのような複数のファイル構成になりました。

.
├── CHANGELOG.md
├── completions
│  ├── fin.fish
│  └── ggl.fish
├── conf.d
│  └── ggl.fish
├── functions
│  ├── fin.fish
│  └── ggl.fish
├── LICENSE
├── README.md
└── test
   ├── myt.fish
   ├── newtest.fish
   └── xdg-test.fish

このように複数のファイルがある場合には、面倒ですが、プラグイン無しだとファイルごとにソースすることになります。

$ source ./functions/ggl.fish
$ soruce ./functions/fin.fish
$ source ./completions/fin.fish
$ source ./completions/gll.fish

おすすめのプラグイン

プラグイン作成では次の 3 つのプラグインが参考になりました(すべて MIT ライセンスです)。細かいところまでは見ていませんが、自分のような初心者にとって関数の定義方法やオプション分岐などが参考になる部分が結構ありました。興味があれば確認してみください。

Function, Builtin, External command

他のシェルでも大体同じらしいですが、自分はほぼ最初のシェルが fish なので呼び出すことのできるコマンドに種類が大きく分けて 3 つあることを知りませんでした。まずは、基礎知識としてそちらについて解説します。

コマンドラインから特定のコマンド名を入力して実行すると呼び出される可能性のあるものは次の 3 つとなります。

  • Function : 他のコマンドをグルーピングし、名前を付けて実行できるようにして定義したコマンド。自作したものや、fish shell から提供される特定の external command のラッパーやユーティリティ(lsmanhelp 等)など。
  • Builtin (内部コマンド: Internal command) : fish shell に組み込まれて提供されているコマンド類(and, argparse, for など)
  • External command (外部コマンド) : 実行可能ファイル(fish 自体とは関係のないプログラム)で、/bin/usr/bin/usr/local/bin/opt/homebrew/bin/ といったディレクトリの種類ごとに配置されている。OS にもともと入っているUNIX command(OS に同梱されていることからincluded commandとも呼ばれることもあるらしい[1])や、Homebrew などを使って自分で入れることもできるパッケージのコマンド。

コマンドラインに入力したコマンドについて、どのカテゴリーのものが呼び出されるかということは type という fish shell が提供するビルトインコマンドで調べることができます。

type は「コマンドラインに入力したコマンドがfish shellにおいてどのように解釈されるかを示す」コマンドです。例えばこの type 自体を調べてみます。

❯ type type
type is a builtin

これは、コマンドライン上で type を入力して実行した際に builtin の type として解釈され実行されることを意味します。

今度は UNIX command である cat コマンドや Homebrew の brew コマンドを type してみると次の結果になります。これは、それぞれが external command として呼び出されることを示しています。

❯ type cat
cat is /bin/cat
❯ type brew
brew is /opt/homebrew/bin/brew

さらに今度は指定したコマンドのマニュアルを閲覧するコマンドである man コマンドを type してます。

type man
man is a function with definition
# Defined in /opt/homebrew/Cellar/fish/3.3.1/share/fish/functions/man.fish @ line 6
function man --description 'Format and display the on-line manual pages'
    # Work around the "builtin" manpage that everything symlinks to,
    # by prepending our fish datadir to man. This also ensures that man gives fish's
    # man pages priority, without having to put fish's bin directories first in $PATH.

    # Preserve the existing MANPATH, and default to the system path (the empty string).
    set -l manpath
    if set -q MANPATH
        set manpath $MANPATH
    else if set -l p (command man -p 2>/dev/null)
        # NetBSD's man uses "-p" to print the path.
        # FreeBSD's man also has a "-p" option, but that requires an argument.
        # Other mans (men?) don't seem to have it.
        #
        # Unfortunately NetBSD prints things like "/usr/share/man/man1",
        # while not allowing them as $MANPATH components.
        # What it needs is just "/usr/share/man".
        #
        # So we strip the last component.
        # This leaves a few wrong directories, but that should be harmless.
        set manpath (string replace -r '[^/]+$' '' $p)
    else
        set manpath ''
    end
    # Notice the shadowing local exported copy of the variable.
    set -lx MANPATH $manpath

    # Prepend fish's man directory if available.
    set -l fish_manpath $__fish_data_dir/man
    if test -d $fish_manpath
        set MANPATH $fish_manpath $MANPATH
    end

    command man $argv
end

最初に、man is a function with definition とあるので man は定義された関数(function)であることがわかります。また、# Defined in /opt/homebrew/Cellar/fish/3.3.1/share/fish/functions/man.fish @ line 6 とあり、どこで定義されているのかが分かります。

また、最後から 2 行目で command man $argv とあり、結局この man という function は同じ名前の external command である man のラッパーであることがわかります。ちなみに command というコマンド自体は指定した名前のプログラムを実行するというビルトインコマンドです。command man は man という function ではなく、man という名前のプログラムを実行させるという意味です。

type したものが function の場合、長い定義が表示されてしまうので、-t オプションで種類だけ表示させることもできます。

❯ type man -t
function

# external commandの場合にはfileと表示される
❯ type cat -t
file
❯ type brew -t
file

さて、この man プログラム自体はどこにあるかというと which コマンド(これはプログラム)を使って調べることができます。which はユーザーパス内のプログラムファイルを探すプログラムです(ちなみに BSD の which コマンドは C 言語で書かれているそうです)。

❯ which man
/usr/bin/man

これで、man プログラムが /usr/bin/man というところにあることがわかります。

ちなみに which type をすると次のような結果になります。

❯ which type
/usr/bin/type

これは type という名前のプログラムが存在していることを示しますが、コマンドラインに入力して実際に実行されるのは fish shell のビルトインである type です。

その理由は、実際に呼び出されるものに優先度があるためで、以下の優先順位で呼び出されます。

function > builtin > external command 

つまり、ビルトインである type は external command である type よりも優先度が高いので、ビルトインの type が呼び出されるということです。このように、コマンドとして呼び出されるものには優先度があり、自分で定義する funciton などが最優先になるため、仮に同じ名前の builtin や exteranl command があってもそれらは呼び出されなくなります。

意図的に"builtin"または"external command"を指定して呼び出したい場合にはそれぞれの名前がついたビルトインコマンドを付けて使用します。例えば、自分で type という名前の function を作成すると、最優先は function なので自分で定義した関数が呼び出されます。

function type -d "自作したtype function"
    echo $argv "はtypeされません"
    echo "これは自作したtypeで最優先に呼び出されます"
end

このような function を作成し config dir に配置したとしましょう。コマンドラインに type を入力し呼び出してみると、以下のようになります。

❯ type man
man はtypeされません
これは自作したtypeで最優先に呼び出されます

builtin を呼び出す場合にはそのまま builtin というコマンドを頭につけることで呼び出せます。

❯ bultin type man
# 省略。先述のビルトインを使った type man の結果が表示される

external command を呼び出す場合には command というコマンドを頭につけることで呼び出せます。この場合に呼び出されるコマンド種類は環境によって異なります。macOS ならBSD実装の type、Linux のディストリビューションならGNU実装の type が呼び出されます。それぞれで挙動が異なることがあるので注意してください。ググるとでてくるのは基本的に GNU 実装のコマンドの解説です。

❯ command type man
man is /usr/bin/man

ちなみに、すべての builtin コマンドは builtin -n というコマンドで閲覧できます。同じようにすべての function は functions -n で閲覧できます。また、external command については command --all COMMANDNAME で指定した名前のコマンドがあるか調べることができます。

このように、function、builtin、external command の違いを意識し、それぞれを駆使して shell script でプラグインを書いていきます。

findとsourceをラップする

今回作ったプラグインは言ってしまえば、BSD 系の external command である find と fish builtin である soruce コマンドをラップした関数です。付加的に色々な処理をつけたりオプション分岐させて使いやすくしています。前の fish shell の記事で紹介した ggl も BSD の open コマンドや Linux 用ユーティリティである xdg-open コマンドのラッパーといえます。

find コマンドはかなり複雑なオプション処理ができて便利なコマンドです。man find して最初に出てくる説明は「walk a file hierarchy」と出てきますが、要するにディレクトリ階層を再帰的に探索して指定したパターンなどにマッチするファイルやディレクトリを探すコマンドです。

https://www.wikiwand.com/en/Find_(Unix)

source ビルトインコマンドは、ファイルの中身を評価してカレントシェルで新規のコードブロックとして使えるようにするコマンドです。

この 2 つを組み合わせて、find で fish file を探して、source で呼び込ませます。

プラグイン構造

おすすめのプラグインで紹介したものでも使われているのですが、処理が長くなったりするものや何度も使うようなものはメインの関数から分岐させてヘルパー関数として別に定義させると便利です。1 つの fish file で複数の関数を定義できます。

function source-fish
    # body
end

# helper functions
function __source-fish_times
    # body
end

function __source-fish_help
    # body
end

最新バージョンの fish shell でもローカル関数の定義はできず、すべての関数はグローバルスコープになりますが、関数名の頭にアンダースコアをつけつことでヘルパー関数として定義するパターンがよく使われます。またアンダースコアをつけることで functions という定義されているコマンド一覧を表示するコマンドから結果を除外して非表示にできます。すべての定義関数を表示したい場合には functions -a を使います。

前回の記事で紹介した argparse を使って今回もオプション処理を行います。基本はフラグ変数を if 分岐でテストします。ヘルプオプションは基本的にヘルパー関数を使用するように分岐させておくと管理が楽なのでヘルパー関数として別に定義しておきます。

function soruce-fish
    argparse \
            -x 'v,h,r,a,t,c' \
            'v/version' 'h/help' 'r/recent' 'a/all' 't/test' 'c/config' -- $argv
    or return
    
    set --local version_soruce_fish "v0.1.5"
    set --local directory $argv
    # コマンド引数を一旦別の変数にセットする
    
    if set -q _flag_version
        echo "soruce-fish:" $version_source_fish
        return
    else if set -q _flag_help
        ## ヘルパー関数を呼び出す
        __source-fish_help
        return
    else if #他のフラグ処理
        # body
    else if test -n "$directory"
        # コマンド引数があった場合の処理
    else 
        # main 処理
    end
end


function __source-fish_help
    set_color yellow
    echo "Usage:"
    echo "      source-fish [OPTION]"
    echo "      source-fish DIRECOTRIES..."
    echo "Options:"
    echo "      -v, --version   Show version info"
    echo "      -h, --help      Show help"
    echo "      -r, --recent    Find recently modified files & source them"
    echo "      -a, --all       Source all fish files under the current directory"
    echo "      -t, --test      Source all fish files in the \"test\" folder"
    echo "      -c, --config    Source fish files in the config directory"
    set_color normal
end

プラグインの基本構造はこのような感じになります。オプション分岐についてすべてを解説すると長くなるので(というかほぼ同じ処理なので)引数無しで実行した場合の処理と中枢で使用するヘルパー関数について解説していきます。

question loop

今回、コマンドを実行してすぐにメイン処理を行うのではなく、ユーザーに質問を行って実行確認してからメイン処理を行わせるようにします。そういった処理を行うものとして、while ture; BODY; end のループを作成し、中で入力を受け取らせるコマンドを使うというパターンがよく使われるそうです。

https://stackoverflow.com/questions/226703/how-do-i-prompt-for-yes-no-cancel-input-in-a-linux-shell-script

fish では、read という入力を受け取る builtin コマンドが存在します。この read コマンドのオプションをいくつか使い、更に switchcase コマンドを使って分岐作成し、インタラクティブに質問に対する答えを受け取らせます。

先ほど説明したプラグイン構造内の最後の if 分岐である else において次のような処理を行います。

else
    echo "Current:"$cc $PWD $cn
    while true
        read -l -P "Source fish files in this project? [Y/n]: " question
        switch "$question"
            case Y y yes
                # メインのfindとsourceの処理
                return
            case N q n no
                return 1
        end
    end
end

read コマンドでは、-l オプションで指定した文字列(この場合は question)をローカル変数として、必要の答えとして受け取らせた文字を格納します。-P オプションで、指定した文字(この場合は Source fish files in this project? [Y/n]: )をプロンプトとして使えるようにします。これによって、「このプロジェクト内の fish files を source しますか?」という質問がコマンドライン上に表示され、必要に対する答えとして Yyyes を入力したら、メインの処理を、Nqnno などの文字列を入力したらコマンドを終了させる、それら以外の文字列を入力したらループ内で再度質問させるという、処理を実現しています。

この一連のパターンでインタラクティブな処理のループを作成しています。

findでパターンにマッチするファイルを探す

それでは、質問の答えとして yes を選択した場合の処理を解説していきます。やることとしては、カレントディレクトリ内の fish ファイルを探し source させるということですが、まずは find をつかって目的の fish file を探します。

find はかなり細かく条件を絞り込んでファイルをさがすことができます。

今回の場合、fish shell のプラグイン開発用のディレクトリである、functionscompletionsconf.d というディレクトリがカレントディレクトリの直下に存在すると想定して find コマンドにわたすパターンを考えます。

switch "$question"
    case Y y yes
        set --local list_functions (command find . -type f -depth "-3" -path "./functions/*.fish")
        set --local list_completions (command find . -type f -depth "-3" -path "./completions/*.fish")
        set --local list_conf (command find . -type f -depth "-3" -path "./conf.d/*.fish")

        # source処理
        return
    case N q n no
        return 1
end

後でまとめて source させるために、まずはコマンド置換内の find で見つかった fish file までのファイルパスを一旦ローカル変数内に格納させます。

上で説明した通り、他のユーザーの環境に find という名前の function があった場合に備えて明示的に find という名前の external command を使うように command を頭に付けておきます。

set --local list_functions (command find . -type f -depth "-3" -path "./functions/*.fish")
  • (1) find コマンドには、探索開始地点を指定してあげることが必要なので、カレントディレクトリを示す . をまずは指定してあげます(find .)。
  • (2) 探索する対象はディレクトリではなくファイルなので対象を「ファイル」という種類に限定する -type f というプライマリ(pirimary: オプションのようなもの)を指定してあげます(find . -type f)。
  • (3) さらに探索するディレクトリの階層を最大で 3 階層という制限を -depth "-3" というプライマリを使って、念のために与えておきます(find . -type f -depth "-3")。
  • (4) 最後に -path PATTERN というプライマリを使って、探すパターンを指定します。例えば、カレントディレクトリの直下にある functions というディレクトリ内に存在する拡張子が .fish であるファイルの場合には指定する PATTERN として ./functions/*.fish という風になります。* はワイルドカードで任意の文字列を表します。

これで、最終的な find . -type f -depth "-3" -path "./functions/*.fish" が完成しました。同じ用に completionsconf.d ディレクトリについても find します。

見つけることができた fish file が各ディレクトリにおいて 0 個でない場合にはそれらのファイルを soruce させます。source の処理には見つけた個数分処理させる必要があり、さらにこの処理は他のオプション分岐でも活用できるので __source-fish_times という名前のヘルパー関数として独立させます。

switch "$question"
    case Y y yes
        set --local test_flag
        set --local list_functions (command find . -type f -depth $max_find_depth -path "./functions/*.fish")
        set --local list_completions (command find . -type f -depth $max_find_depth -path "./completions/*.fish")
        set --local list_conf (command find . -type f -depth $max_find_depth -path "./conf.d/*.fish")

        test -n "$list_functions"
            and __source-fish_times $list_functions
            and set test_flag "OK"
        test -n "$list_completions"
            and __source-fish_times $list_completions
            and set test_flag "OK"
        test -n "$list_conf"
            and __source-fish_times $list_conf
            and set test_flag "OK"
        not test "$test_flag" = "OK"
            and echo "No files found"
            and return 1
        return
    case N q n no
        return 1
end

test ビルトインコマンドの -n オプションをつかって指定した文字列が 0 でない場合に限ってヘルパー関数に引数として find したパスを渡すようにします。つまり、find できたものだけ soruce させます。どのディレクトリについても find できない場合、つまり test_flag が"OK"ではない場合に「No files found」というメッセージが表示されます。

bulk sourceさせる

さて、find して取得した fish file までの一連のファイルパスをリストとして次のヘルパー関数 __source-fish_times に渡します。

# helper function
function __source-fish_times

    set --local cc (set_color yellow)
    set --local cn (set_color normal)
    set --local ca (set_color cyan)

    set --local argcount (count $argv)
    if test $argcount -ge 1
        for i in (seq 1 $argcount)
            builtin source $argv[$i]
            and echo $ca"-->completed:"$cc $argv[$i] $cn
        end
    else
        echo $ca"No files found" $cn
    end
end

渡されたリスト変数はこの関数内の $argv に渡されます。$argv はリストであり、その中に渡されているはずの find で発見したファイルパスの個数を数えるために count コマンドを使用し、別のローカル変数 argcount にその個数を格納します。この個数が 1 以上であることを確かめるために test $argcount -ge 1 でテストし、1 以上(-ge: greater than or equal)なら内部の処理(source)を行わせます。

seq 1 $argcount で 1 からリスト要素の個数分の数列を作成し、for ループ内でその回数分 source を実行させます。ここでも念のために source を builtin であると明示して builtin source を使います。

そして、source がうまく行った場合には、and コマンドで条件判定して completed というメッセージとともに対象となったファイルパスをコマンドライン上に echo させます。見やすくさせるために、カラーもつけています。次のような感じの結果となります↓

image

このような感じでプロジェクト内の fish ファイルを発見し source させるプラグインが完成しました。

実際のプラグインでは、以下の機能も実装しています。

  • config directory にあるスニペットも soruce させる
  • カレントディレクトリ内の指定したディレクトリを source させる
  • テストディレクトリ(test/tests/)を source させる
  • 最近変更したファイルのみを source させる

興味のある方は Github でソースコードを公開していますので確認してみてください。

https://github.com/yo-goto/source-fish
脚注
  1. Command-line interface - Wikiwandの"Anatomy of a shell CLI"の項目では、コマンドは通常、internal command, inclueded command, external command の 3 つのうちのどれかであると記載されている。 ↩︎

Discussion

ログインするとコメントできます