自作Zshプラグイン・CLIツールの使用状況報告

ここ数年の間にいくつかの小さいZshプラグインCLIツールを作成しました。

頻繁に使用しているものもあればその実そうでないものもあります。本記事では現在の自作プラグイン・ツールの使用状況を報告します。

1. Almel

Almelは高速に動作するカスタマイズ性に優れたシェルプロンプトです。

ryooooooga.hateblo.jp

かれこれ3年以上使用しており、自作Zshプロンプトを非同期対応した - 茅の下で紹介したように非同期描画に対応したことで(自作なので当たり前ではあるのですが)不満点を克服し、より手放し難いものになりました。

ryooooooga.hateblo.jp

上記記事を書くにあたり参考のために人気の高い Powerlevel10k を使用したこともあったのですが、セットアップの容易さなどを鑑みても今後他のプロンプトに乗り換えることはおそらくありません。

今後の機能追加を積極的に行っていくつもりはない(なにせ現状に満足しているため)一方で、コンフィグファイルの構造などには反省点も多いので、別の名前で作り直すことはひょっとしたらあるかもしれません。

2. Zouch

Zouchはテンプレートから新規ファイルを生成するコマンドです。

ryooooooga.hateblo.jp

これは現在でもちょくちょく使っており、また-rオプションを付けることで中間ディレクトリの作成も行えて便利なため、touchコマンドの代わりに使用することもしばしばです。

もっともよく生成しているファイルはおそらく .editorconfig.gitignore で、これらはFZFを利用してインタラクティブに内容を生成できるようにしています。

一方で、一定のルールに従って複数のファイルを同時に出力したい (hoge.gohoge_test.go, src/main/java/.../Piyo.javasrc/test/java/.../PiyoTest.java など) という瞬間も多く、そのような場合のためにmdmgの導入を検討しています。

blog.himanoa.net

3. zabrze

zabrzeは略語展開を行うZshプラグインです。

ryooooooga.hateblo.jp

もっとも使用頻度が高く、もっとも生産性を向上させているZshプラグインです。

evaluateやzabrzeのアイデアの根幹であるcontextといった機能は上記記事公開後に(パクリ元である)zeno.zshにも逆輸入され、さらにzeno.zshの実行パフォーマンスも改善されたため記事中で紹介したいくつかのユースケースはzeno.zshで代替可能になったのですが、その後のzabrzeへの機能追加によってより発展的な展開が行えるようになりました。

追加された機能:

  1. 正規表現による略語マッチ
  2. コマンド全体の置換
  3. 条件付きの略語展開

1. 正規表現による略語マッチ

マッチする略語のパターンを正規表現で指定できるようになりました。

abbrevs:
  # cp を cp -i に, mv を mv -i  に展開する 
  - name: cp/mv
    abbr-pattern: ^(cp|mv)$
    snippet: $abbr -i # マッチした略語は $abbr という変数でアクセスできる
    evaluate: true

  # .N を awk '{ print $N }' に展開する
  - name: .N
    abbr-pattern: ^\.\d+$
    snippet: awk '{ print ${abbr/./$} }'
    evaluate: true

これにより、複数の略語を1つの設定で記述できるようになりました。

2. コマンド全体の置換

以前は入力の置換対象は略語のみに限られていましたが、コマンド全体を置換することが可能になりました。

abbrevs:
  # cd - を popd に展開する
  - name: cd -
    abbr: "-"
    snippet: popd
    action: replace-all # コマンド全体を置換する
    global: true
    context: ^cd\s+-$

また、上で紹介した正規表現による略語マッチと組み合わせることでsuffix aliasを実現できるようになりました。

abbrevs:
  # *.py を python3 *.py に展開する
  - name: python3 *.py
    abbr-pattern: \.py$
    snippet: python3 $abbr
    evaluate: true

3. 条件付きの略語展開

略語展開の条件を設定できるようになりました。

これにより「macOSでのみ展開する」、あるいは「特定のコマンドが存在している場合のみ展開する」略語が実現できるようになりました。

abbrevs:
  # macOSでのみ展開される
  - name: chrome
    abbr: chrome
    snippet: open -a 'Google Chrome'
    if: '[[ "$OSTYPE" =~ darwin ]]'

  # trashコマンドが存在するときのみ rm を trash に展開する
  - name: trash
    abbr: rm
    snippet: trash
    if: (( ${+commands[trash]} ))

  # trashコマンドが存在しないときは rm を rm -ri に展開する
  - name: rm
    abbr: rm
    snippet: rm -ri

4. qtmut

qtmut はTmuxinatorの代替ツールです。

ryooooooga.hateblo.jp

あんまり使っていません (ZLEに組み込んでいるため呼び出しはしているが、本来の用途では使っていない)。Tmuxを生で呼び出す場合に比べやや遅く感じるためRustなどを用いて再実装することを考えていますが、使用頻度は高くないので腰は重いです。

5. commitizen-deno

commitizen-deno はDeno製のcommitizenクライアントです。

ryooooooga.hateblo.jp

たまに使用します。が、直接 lazygit で feat(Hoge): .... のようなコミットメッセージを入力してしまうことの方が多いです。

6. qwy

qwy はpmyのクローンです。

github.com

ryooooooga.hateblo.jp

紹介記事を書いたことはありませんが、半年ほど前に作成したツールです。

pmyから使用していない機能を削除し、設定ファイルの項目を整理することで設定間の再利用性を高めたものです。が、多くの人にとってはzeno.zshで十分なのでREADMEからして他人に使わせる気がありません。

現在運用している設定のほぼ全てが以前pmyで使用していたものを移植したものです。

ファイル名の補完に利用することが多く、1日に1回程度のあまり高くない頻度で利用しています。

まとめ

よく使用しているものもあればそうでもないものもありますが、使っていない=存在している価値がない, 作る価値がない、ということではないですし、手札を増やすことは良いことなので今後もそんなに使わないものを作っていきたいですね。

自分の運用している設定はdotfilesリポジトリで確認できます。

ヘッダーオンリーのC言語向け単体テストフレームワークCUTEを作った

C99以降向けのヘッダーオンリーで動作する単体テストフレームワークCUTEを作成しました。

github.com

モチベーション

C言語のコードに対する単体テストを書く際、ごく簡単なものであれば assert() を使用することが多いと思います。

しかし、テスト対象が複雑であったり、テストの分量が多い場合には assert() のみでは機能不足を感じることが今まで多々ありました。

そこで、自分用に必要十分な機能を備え、なおかつシンプルな使い心地の単体テストフレームワークを作成しました。

動作環境

C89以前、およびMSVCはサポートしていません。

使用方法

CUTEはヘッダーオンリーのライブラリであるため、適当なディレクトリに cute.h をダウンロードし、includeする だけで使用できます。

また、CMakeを使用している場合はFetchContentを使用することで以下のようにプロジェクトをインポート出来ます。

include(FetchContent)

FetchContent_Declare(cute
    GIT_REPOSITORY "https://github.com/Ryooooooga/cute.git"
    GIT_TAG        "main"
)

FetchContent_MakeAvailable(cute)

target_link_library(your_project cute)

コード例

CUTEを使用した簡単なテストの記述例を以下に示します。

#include "cute.h"

int factorial(int n) {
    if (n > 0) {
        return n * factorial(n - 1);
    }
    return 1;
}

TEST(factorial) {
    EXPECT(factorial(0), eq(1));
    EXPECT(factorial(1), eq(1));
    EXPECT_MSG(factorial(5), eq(120), "5! == 120 (actual %d)", factorial(5));
}

TEST(string) {
    const char *s = "Hello, world!";

    ASSERT(s, is_not_null);
    EXPECT(s, eq_str("Hello, world!"));
    EXPECT((s, 4), eq_str_n("Hell"));
    EXPECT(s, contains("world"));
    EXPECT(s, not(contains("nya")));
}

int main(void) {
    RUN_TESTS() {
        RUN(factorial);
        RUN(string);
        return DUMP_RESULT();
    }
}

その他の例は example.c で確認できます。

基本的な書き方

上記の通り、基本的な条件は以下のような形式で記述します。

  • EXPECT(実際の値, 述語);
  • EXPECT_MSG(実際の値, 述語, メッセージ);
  • ASSERT(実際の値, 述語);
  • ASSERT_MSG(実際の値, 述語, メッセージ);

EXPECT()は条件が満たされなかった場合も以降のテストが継続されるのに対して、ASSERT()は即座にエラーの発生したテストを中断します *1

述語

現在のところ、値に対する述語として以下のようなものが用意されています。

  • is_true: actual == true
  • is_false: actual == false
  • is_null: actual == NULL
  • is_not_null: actual != NULL
  • eq(x): actual == x
  • ne(x): actual != x
  • lt(x): actual < x
  • le(x): actual <= x
  • gt(x): actual > x
  • ge(x): actual >= x
  • eq_str(s): actual == s
  • eq_str_n(s): actual[0..n] == s ※ このeq_str_n() のみ EXPECT((ptr, len), eq_str_n(s)) のように記述します。
  • contains(s): actual が s を含む
  • not(pred): 述語predを反転する

また、述語そのものはマクロや関数ではないため、以下のように同名の変数や関数が定義されていても問題なく動作します。

bool is_true = true;
EXPECT(is_true, is_true);

int eq = 42;
EXPECT(42, eq(eq));

グルーピング

GROUP() を使用することでテストコードをグルーピングすることが出来ます。

TEST(numeric) {
    GROUP("integer") {
        int n = 42;

        EXPECT(n, eq(42));
        EXPECT(n, ne(41));
        EXPECT(n, lt(43));
        EXPECT(n, le(42));
        EXPECT(n, gt(41));
        EXPECT(n, ge(42));
    }

    GROUP("floating point") {
        double f = 3.14159265;

        EXPECT(f, ne(0));
        EXPECT(f, lt(3.15));
        EXPECT(f, le(3.15));
        EXPECT(f, gt(3.14));
        EXPECT(f, ge(3.14));
    }
}

出力

CUTEは実行されたテストケースを出力します。 また、テストケースやグループごとに実行時間の計測結果を出力するため、簡単なベンチマークとしても利用できます。

もしテストの実行中にエラーが発生した場合、その周囲のアサーションなどを出力します。 問題のない成功ケースの出力を省略することでエラー箇所の把握がしやすいようになっています。

仕組み

CUTEの大半の機能はマクロを用いて実装されています。

メインの機能であるアサーションは以下のようなコードに展開されます。

ASSERT(x, eq(42));
    ↓
CUTE_ASSERT(x, eq(42));
    ↓
CUTE_ASSERT_I(CUTE_TESTING, x, CUTE_PRED_eq(42));
    ↓
CUTE_ASSERT_II(cute_testing, x, CUTE_PRED_eq_COND, CUTE_PRED_eq_DESC, 42);
    ↓
CUTE_ASSERT_III(cute_testing, CUTE_PRED_eq_COND(x, 42), CUTE_PRED_eq_DESC(x, 42));
    ↓
CUTE_ASSERT_III(cute_testing, (x == 42), "x == 42"));
    ↓
do {
  if (!cute_assert(cute_testing, (x == 42), "x == 42", __FILE__, __LINE__)) {
    return;
  }
} while (0);

また、グルーピングは以下のように展開されます。

GROUP("group %d", 42) { ... }
    ↓
CUTE_GROUP("group %d", 42) { ... }
    ↓
CUTE_GROUP_I(CUTE_TESTING, "group %d", 42) { ... }
    ↓
CUTE_GROUP_I(cute_testing, "group %d", 42) { ... }
    ↓
for (
  cute_testing_t *_cute_tmp = cute_testing,
                 *cute_testing = CUTE_GROUP_START(_cute_tmp, "group %d", 42);
  cute_testing;
  CUTE_GROUP_FINISH(cute_testing), cute_testing = NULL
) { ... }

まとめ

ヘッダーオンリーのシンプルな単体テストフレームワークを作成しました。

個人のCプロジェクトの単体テストに用いてみたところひとまず問題なさそうなので満足しています。

*1:このあたりの命名GoogleTest に倣っています

自作Zshプロンプトを非同期対応した

自作のシェルプロンプト、almelを非同期対応させることで巨大なリポジトリ内での硬直を軽減し、使用感を向上させました。

github.com

Almel とは

almel はRust製の高速に動作するシェルプロンプトです。 現在はBash, Zsh, fishに対応しています*1

以前の記事:

ryooooooga.hateblo.jp ryooooooga.hateblo.jp

Almelはlibgit2を用いることで高速にGitステータスを表示しています。

ですがこれまでは常に同期的にGitステータスを取得していたため、巨大なリポジトリ内では場合によって描画に〜1秒程度の時間がかかることがあり、発生する待ち時間がストレスになることがありました。

既存の非同期プロンプト

Powerlevel10kagkozak Zsh Promptなどの既存のZshプロンプトの中には、非同期に描画することでスループットを向上させているものがあります。

github.com github.com

これらは、Gitステータスなどの実行に時間の掛かるセグメントを除外した"仮のプロンプト"を同期的に表示し、その後Gitステータスを含む完全なプロンプトを非同期的に描画することでコマンド実行後の硬直を軽減し、利用体験を向上させています。

Almelの非同期対応

今回、Almelを非同期対応させることでこれらと同様に利用体験を向上させました*2

非同期描画にはzsh-asyncを利用しているため、あらかじめなんらかの方法でzsh-asyncをインストールしておく必要があります*3

github.com

初期化方法:

# .zshrc
# zsh-asyncを読み込む
source "${zsh-asyncのインストールディレクトリ}/async.zsh"

# Almelを初期化する
eval "$(almel init zsh --async)"

Zinitを用いた初期化方法:

# .zshrc
zinit light-mode from'gh-r' as'program' for \
    atload'eval "$(almel init zsh --async)"' @'Ryooooooga/almel'

zinit wait lucid blockf light-mode for \
    @'mafredri/zsh-async'

パフォーマンスについて

では、もともとGitに関する情報の取得にどの程度の時間がかかっていたのか測定してみます。

小さいリポジトリでの計測

まず比較的小規模であるAlmel自身のリポジトリを用いて、Gitリポジトリに関する情報取得の有無による実行速度の差を確認します。

# Gitステータスあり
$ time (for i in {1..100}; do almel prompt -s0 -j0 -d1 zsh >/dev/null; done)
( for i in {1..100}; do; almel prompt -s0 -j0 -d1 zsh > /dev/null; done; )  1.16s user 0.71s system 83% cpu 2.248 total
# 平均 22.48ms (N=100)

# Gitステータスなし
$ time (for i in {1..100}; do almel prompt -s0 -j0 -d1 zsh --no-git >/dev/null; done)
( for i in {1..100}; do; almel prompt -s0 -j0 -d1 zsh --no-git > /dev/null; ;   0.83s user 0.46s system 78% cpu 1.629 total
# 平均 16.29ms (N=100)

ここで利用している almel prompt コマンドはAlmelのプロンプトの実際の表示を行っているコマンドで、--no-git オプションを渡すことでGit関連の機能を全て無効化できます。

差はわずかではありますが、当然ながら--no-gitオプションありのほうが速いことがわかります。

Profiling Rust - macOS で DTrace を使って FlameGraph を描画する - Qiita を参考にプロファイリングを行ってみると、Gitステータスの取得 (下図 右 緑矩形) に最も時間が掛かり、次いで Gitリポジトリの初期化 (下図 左 青矩形) に時間が掛かっていることがわかります。

f:id:Ryooooooga:20220408185040p:plain
Almelのプロファイル結果 (青: Gitリポジトリの初期化, 緑: Gitステータスの取得)

Gitステータスの原理からして、リポジトリに含まれるファイルの数が多いほどより多くの時間が必要になります。

やや大きいリポジトリでの計測

続いて、手元にあったリポジトリの中で最も体感の描画時間が長かった lazygit を用いて計測を行います。

# Gitステータスあり
$ time (for i in {1..100}; do almel prompt -s0 -j0 -d1 zsh >/dev/null; done)
( for i in {1..100}; do; almel prompt -s0 -j0 -d1 zsh > /dev/null; done; )  10.24s user 15.13s system 95% cpu 26.438 total
# 平均 264.38ms (N=100)

# Gitステータスなし
$ time (for i in {1..100}; do almel prompt -s0 -j0 -d1 zsh --no-git >/dev/null; done)
( for i in {1..100}; do; almel prompt -s0 -j0 -d1 zsh --no-git > /dev/null; ;   0.47s user 0.42s system 73% cpu 1.207 total
# 平均 12.07ms (N=100)

Gitステータスありの場合、260ms以上も掛かっていることがわかります。 これほどになると同期描画の硬直はストレスに感じます。

まとめ

Almelのプロンプトの描画を非同期化することで利用体験を向上させました。

以前からGitステータス取得のパフォーマンスの低下は懸念点であったため、それが解消できたことは喜ばしいです。

また、almel init zsh --asyncで出力されるスクリプト(下記)は非常に短く簡単であるため非同期プロンプトを実装する際の参考になるでしょう。

github.com

*1:fish用の出力を利用することで、がんばればPowerShellでも動かせることを確認しています。

*2:現在、非同期描画に対応しているのはZshのみです。

*3:zsh-asyncが読み込まれていない場合は通常通り同期的に描画されます。

Denoでcommitizen的なツールを作った

Denoでcommitizen互換のツールを作りました。

f:id:Ryooooooga:20220121200521p:plain

github.com

commitizenはGitのコミットメッセージをいい感じにするためのツールです。

commitizenのようなツールには公式である commitizen/cz-cli の他に、streamich/git-cz や Go製の lintingzhen/commitizen-go があります。

以前は cz-cli を使用していたのですが、これは Node.js 製であるため、グローバルに npm install する必要があります。 しかし、私はグローバルに npm install, gem install, pip install をあまりしたくないため、これに若干の不満がありました。

commitizen-go はNode.jsに依存しませんが、あまり開発が活発ではなさそうな上、入力にEmacsキーバインドが使用できないことから移行先としては若干心許ないように思えたため、似たようなものを自作することにしました。

  • commitizen-deno について
    • インストール方法
    • 使用方法
    • コンフィグ
  • なぜDenoを選択したか
  • なぜFZFを利用したか
    • 自由入力に FZF を用いる
    • コミットメッセージのプレビュー
  • まとめ
続きを読む

Tmuxinatorの代替ツールをDenoで作った

Tmuxinator の代替CLIツール qtmut *1 を作りました。

github.com

  • 1. これはなに
  • 2. 使い方
  • 3. なぜ自作したか
    • 3.1. 普段のTmuxの利用方法
    • 3.2. Tmuxinator の導入
    • 3.3. Tmuxinator と qtmut の思想の違い
  • 2. なぜDenoを選択したか
  • 3. まとめ

*1:qtμt というマンガから取りました。https://manga.line.me/product/periodic?id=Z0000153

続きを読む

Git 2.0.0から2.33.1までのDockerイメージを作った

Git 2.0.0から2.33.1までの各バージョンのDockerイメージを作りました。

hub.docker.com

github.com

Git のバージョンごとの挙動を検証したかったのですが、丁度いいDockerイメージが無かったので作りました。

ベースイメージは扱いやすいようにUbuntuを採用し、マルチステージビルドを活用することで1つあたりの容量が小さくなっています (約130MB)。

イケてないところ

イメージのビルドをローカルマシン上で行っています。 1バージョンあたり2~4分かかるため、全191バージョンで7時間かかりました。

文脈を指定可能な自動展開されるglobal aliasを実現するZSHプラグイン zabrze を作った

ZSHにはglobal aliasという機能があります。 これは、行頭以外に入力された場合にも展開されるaliasです。

$ alias -g B='"$(git symbolic-ref --short HEAD)"'
$ git push -u origin B
# → git push -u origin [現在のブランチ名]

使いこなせれば便利そうな一方で、global aliasはグローバル名前空間を汚染するため、導入には慎重さを要します*1

また、コマンドヒストリーには通常のaliasと同様にglobal aliasの展開前の入力が保存されます。

これら2つの問題点を解決することを目的に、zabrze というZSHプラグインを作成しました。

github.com

これはなに

zabrze *2 は aliasをYAMLで記述できるようにするZSHプラグインです。

1. aliasの自動展開

例えば下のような設定ファイルを ~/.config/zabrze/config.yaml に記述した場合、行頭で g<スペース> と入力したとき、即座に git と展開されます *3

また、null<スペース> と入力した場合は行頭以外でも >/dev/null に展開することができます。

# ~/.config/zabrze/config.yaml
abbrevs:
  # alias
  - name: git
    abbr: g
    snippet: git

  # global abbrev
  - name: '>/dev/null'
    abbr: null
    snippets: '>/dev/null'
    global: true

2. 文脈を指定した global alias

さて、ここまでであれば後述する既存のZSHプラグインによってすでに同様の機能が実現されています。

zabrze ではaliasの自動展開に加え、展開の有無をその文脈によって切り替えられるようになっています。

  # git コマンドを入力しているときのみ展開されるglobal alias
  - name: branch
    abbr: B
    snippet: $(git symbolic-ref --short HEAD)
    evaluate: true # snippetの文字列をスクリプトとして解釈する
    global: true
    context: '^git ' # 展開される文脈を正規表現で指定する

上のような global alias B は、git コマンドを入力しているときのみ展開されます。

$ git push -u origin B<エンター>
# ↓ に展開され、実行される
# git push -u origin [現在のブランチ名]

$ echo B<スペース>
# ↑ は展開されない 

これにより、global alias自体の有用性を保ったままグローバル名前空間の汚染を避けることができます。

類似のZSHプラグイン

zsh-abbrev-alias

github.com

qiita.com

zsh-abbrev-alias は自動展開される alias コマンド、abbrev-alias コマンドを追加するシンプルなプラグインです。

zeno.zsh

github.com

zenn.dev

zeno.zsh は Deno 製のZSHプラグインです。 abbrev-alias相当の機能に加え、pmyのような補完機能を有しています。

一方、実行にはDenoのインストールを要求し、またスペースを押したときの動作が重い印象があります (おま環かも)。

開発のモチベーション

以前は zsh-abbrev-alias を利用していましたが、Gitなどのサブコマンドにabbrev-aliasを適用できないことを不満に思っていました。

$ abbrev-alias g=git
$ g<SP>  # git に展開される

$ abbrev-alias -g c=commit
$ git c<SP>  # git commit に展開されるがグローバル名前空間の汚染がきびしい

$ git config alias.c commit
$ git c  # history には `git commit` で記録されてほしい

現在は以下のような zabrze の config.yaml によって Git aliases の自動展開を実現しています。

github.com

*1:global aliasの活用例で紹介されるものは1~4文字程度の短いものが多い印象があります (長いglobal aliasの利点は少ないため)

*2: z (ZSHの頭文字) + abbr でそれっぽい単語がないか探した結果の命名です。期せずして almel, zouch と同じく地名になりました

*3:スペースではなく、エンターキーを入力した場合も同様に展開されます