richなcatコマンドを作った話

## 作ったもの

コマンドシンタックスハイライトをしてくれたり、Markdown や Jupyter Notebook をプレビューしてくれたりと、出力が"rich"な cat コマンド「richcat」を作りました。

https://github.com/richcat-dev/richcat

pip を用いてインストールして使うことができます。

```
$ pip install richcat
```

richcat の主な機能は以下の通りです。

### シンタックスハイライティング

![image](https://user-images.githubusercontent.com/55144709/152114834-9172b501-269a-4044-9889-94c92346c5ff.png)

### プレビューのサポート

#### Markdown

![image](https://user-images.githubusercontent.com/55144709/152116094-d20ea35a-5dbd-441c-b668-2a3f408caaaf.png)

#### CSV

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/489030/d8e3e3af-7114-ea24-cf04-7cf9e2cb9aae.png)

#### Jupyter Notebook

※ Jupyter Notebook をプレビューするには、予め Jupyter がインストールされている必要があります
※ Jupyter がインストールされていない場合はJSONファイルとしてプレビューされます

![image](https://user-images.githubusercontent.com/55144709/154985162-d177e0ac-4f26-47e2-a49b-5008883e541f.png)

## なぜ作ったのか？

fzf のプレビューウィンドウに出すファイルプレビューをもっと良い感じにしたかったからです。具体的には…

↓これを
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/489030/033df7f1-100d-3b5b-4921-9d191e9869d1.png)

↓こうしたかった
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/489030/3d6280bc-e46d-737b-4f14-5844165630e8.png)

良い感じのファイルプレビューですが、ファイル形式ごとに適したツールは既に存在しています。

- Python や JavaScript といったソースコード等のファイル　→ [bat](https://github.com/sharkdp/bat) 
- CSV ファイル　→ column コマンド
- Jupyter Notebook ファイル　→ [nbconvert](https://nbconvert.readthedocs.io/en/latest/) 、 [nbcat](https://github.com/jlumpe/nbcat)

etc...

しかし、コマンドライン上やシェルスクリプト内でファイル形式ごとにそれらのツールを使い分けるのは難しく、メンテナンス性も良くありませんでした。そこで、「一発でファイル形式ごとに最適なプレビューを提供してくれるコマンド」を作って実現しよう、ということになりました。

※ なぜ「fzf のプレビューウィンドウに出すファイルプレビューをもっと良い感じにしたい」と思ったのかですが、知人が開発している[Fuzzy-Explorer-on-Terminal (fet)](https://github.com/Fuzzy-Explorer/Fuzzy-Explorer-on-Terminal)という fzf 製 CUI ファイルエクスプローラで使いたかったからです。

https://github.com/Fuzzy-Explorer/Fuzzy-Explorer-on-Terminal

## どうやって作ったのか？

### 使用技術

- 言語：Python
- ライブラリ：[rich](https://github.com/Textualize/rich)

https://github.com/Textualize/rich

技術選定は、標準出力を色々カスタマイズできるライブラリを探していたところ「rich」が見つかり、「rich」が Python 製だったので言語も Python に――というような流れで進みました。

### 仕組み

rich の次の 3 つの機能をファイル形式ごとに使い分けることで実現しています。

| rich の機能                                                       | どんな機能か                                 | 利用しているファイル形式 |
| ----------------------------------------------------------------- | -------------------------------------------- | ------------------------ |
| [`Markdown`](https://rich.readthedocs.io/en/latest/markdown.html) | Markdown ファイルをプレビューする機能        | `.md`                    |
| [`Table`](https://rich.readthedocs.io/en/latest/tables.html)      | 罫線等を用いてテーブルをきれいに表示する機能 | `.csv`                   |
| [`Syntax`](https://rich.readthedocs.io/en/latest/syntax.html)     | シンタックスハイライトを付ける機能           | その他                   |

ただし、 `.ipynb` ファイルだけ Jupyter がインストールされているかどうかで利用する機能を切り替えています。

- インストールされていなかった場合：
  - JSON ファイルとして扱う　 → 　`Syntax` を利用
- インストールされていた場合：
  - Jupyter の `nbconvert` 機能で Markdown ファイルへ変換　 → 　`Markdown` を利用

実行～標準出力までの大まかな流れは以下の通りです。

1. コマンド引数を受け取る
1. 引数中のファイルパスをチェックし、この後の処理に通して良いか調べる。チェックが通らなかった場合は、その原因を例外として標準出力する
1. ファイル形式を判別する。判別後、形式ごとに標準出力するための処理を行う
    - `.ipynb` 形式だった場合：
        - Jupyter の `nbconvert` 機能で Markdown ファイルへ変換する（以後、`.md` 形式だった場合の処理と同様）
    - `.md` 形式だった場合：
        - rich で Markdown ビューを作成する
    - `.csv` 形式だった場合：
        - rich で Table ビューを作成する
    - その他の場合：
        - rich でシンタックスハイライトをつける
1. 標準出力する

[![](https://mermaid.ink/img/pako:eNp9ks1K60AUx18lzMqCvkAXwoW61IV2l3QxzUxtMJmUZFItbReZWVh1ET9oRfwApUVF9F64Cz9u5T7M6eQ57rSpl2rBLIaT5P_7nXBOmsj2CUV5VHH9bbuKA24UCxYz9BVyfbdgbhR_rBdLuezZ0tIyiN8gr0Dqc08Nu2n3F4hjy2IqOYX4SCU9EAf_w1lg9PII8RPILohrEH2QDyCPQLxmIMgYxC1ICeInxGezeMVxKW_UaHOWVe83aphMm3b6qjPIqHaGfTAabzm1Biu3DNtndY-YrDwuaMAhvtXsKg62iL_Npr74QfX30uS8pMFNyqhH5n0eaRnZOzNw7OpnD8gTkAOQQ_1po_eLtHM4VoWc-BE307uz9K2ndt_U_nlpXmyH9czMy-6MuojLLv3OO2-C-FLPevSn1zLCBuN4Z0YH4nm8N_F3Omy9AZlMhno_OTu6A8R9vUY9zq9NsnqyFLZgrqwVSjm0iDwaeNgh-gdqjmMW4lXqUQvldUloBUcut5DF2joa1QjmdIU43A9QvoLdkC4iHHF_o8FslOdBRD9CBQdvBtibptr_AGS5PP8)](https://mermaid.live/edit#pako:eNp9ks1K60AUx18lzMqCvkAXwoW61IV2l3QxzUxtMJmUZFItbReZWVh1ET9oRfwApUVF9F64Cz9u5T7M6eQ57rSpl2rBLIaT5P_7nXBOmsj2CUV5VHH9bbuKA24UCxYz9BVyfbdgbhR_rBdLuezZ0tIyiN8gr0Dqc08Nu2n3F4hjy2IqOYX4SCU9EAf_w1lg9PII8RPILohrEH2QDyCPQLxmIMgYxC1ICeInxGezeMVxKW_UaHOWVe83aphMm3b6qjPIqHaGfTAabzm1Biu3DNtndY-YrDwuaMAhvtXsKg62iL_Npr74QfX30uS8pMFNyqhH5n0eaRnZOzNw7OpnD8gTkAOQQ_1po_eLtHM4VoWc-BE307uz9K2ndt_U_nlpXmyH9czMy-6MuojLLv3OO2-C-FLPevSn1zLCBuN4Z0YH4nm8N_F3Omy9AZlMhno_OTu6A8R9vUY9zq9NsnqyFLZgrqwVSjm0iDwaeNgh-gdqjmMW4lXqUQvldUloBUcut5DF2joa1QjmdIU43A9QvoLdkC4iHHF_o8FslOdBRD9CBQdvBtibptr_AGS5PP8)

## 工夫した点

### 抽象クラスして同じ処理を共通化
前述のとおり、 richcat は rich の 3つの機能（ `Syntax` 、 `Markdown` 、 `Table` ）を利用してプレビューを行っています。どの機能を利用するかで処理内容は異なりますが、実装すべき処理（次の4つ）はいずれも同じです。

- ファイル読み込み
- ターミナル幅の取得
- プレビューの作成
- プレビューの標準出力

そこで、抽象クラスを用意し、処理内容が同じものは共通化、異なるものは抽象メソッドを用意して実装を強要するようにしました。実際の実装のクラス図を次に示します。

[![](https://mermaid.ink/img/pako:eNq1k89OwzAMxl-lymkr6wtUXJDguAtwrBR5ibtGy58qcbVVY-9O2g6RQjW0AzlFn3_x59jJmQknkZVMaAjhWcHeg6lsFtfTLpAHcXhVotnCAX32-FEU2VtvCU6jcJPbgj9Id7R_k--w05hgYykL8HkKD6sg8HskflSSmkQWTjvPQx8ITSJLJXigXmOi1UpjC7PTg8SFs4SWwg-d-jY5_dB6ZWm1TqChHEJvlAU91TULSxRKjtmDiy6_AY8g-eC0WueJbOLVuY89iMlP0THPpuAl7VUyk1mX7jG94bngOJvuf3jmC6bfD2XmeNcl2IaZOCVQMj76MUvFqEGDFSvjVmINnaaKVXZAu1YC4YtU5Dwra9ABNww6crHjgpXkO_yCrn_nSl0-AT89DBI)](https://mermaid.live/edit#pako:eNq1k89OwzAMxl-lymkr6wtUXJDguAtwrBR5ibtGy58qcbVVY-9O2g6RQjW0AzlFn3_x59jJmQknkZVMaAjhWcHeg6lsFtfTLpAHcXhVotnCAX32-FEU2VtvCU6jcJPbgj9Id7R_k--w05hgYykL8HkKD6sg8HskflSSmkQWTjvPQx8ITSJLJXigXmOi1UpjC7PTg8SFs4SWwg-d-jY5_dB6ZWm1TqChHEJvlAU91TULSxRKjtmDiy6_AY8g-eC0WueJbOLVuY89iMlP0THPpuAl7VUyk1mX7jG94bngOJvuf3jmC6bfD2XmeNcl2IaZOCVQMj76MUvFqEGDFSvjVmINnaaKVXZAu1YC4YtU5Dwra9ABNww6crHjgpXkO_yCrn_nSl0-AT89DBI)

これにより、各々実装したい部分のコードだけ書けば良いので実装負担が軽くなった他、メンテナンスもしやすくなりました。

### エラーハンドリング

`No such file of directory` や `Is a directory` といった、エラー内容を標準出力する仕組みを richcat でも作りました。実装は、richcat のプログラム全体を try-except で囲い、エラーが発生したら対応する例外クラスを raise させる方法を採りました。イメージとして、以下にその実装コードの抜粋を示します。

```python
def check_input_error(args):
    # Is exists
    if args.filepath is None:
        return
    if not os.path.exists(args.filepath):
        raise RichcatFileNotFoundError(args.filepath)
    # Is directory
    if os.path.isdir(args.filepath):
        raise RichcatIsDirectoryError(args.filepath)
    # Is able to access
    if not os.access(args.filepath, os.R_OK):
        raise RichcatPermissionError(args.filepath)

def richcat(args):
    try:
        # help
        if args.help:
            args.file_contents, args.filetype, args.filepath = print_help()
        # Checking input error
        check_input_error(args)
        # Infering FileType
        filepath, filetype = infer_filetype(args.filepath, args.filetype)
        # Print Rich
        try:
            print_rich(filetype, float(args.width), args.color_system, args.style, filepath=filepath, file_contents=args.file_contents)
        except BrokenPipeError:
            raise RichcatBrokenPipeError()
    except Exception as e:
        if 'print_error' in dir(e):
            e.print_error()
        else:
            raise e
```

これによりエラー後の処理を1か所にまとめることができ、似たようなエラー後処理があちこちに散らばるのを防ぐことができました。

### シンタックスハイライトの自動決定処理の高速化

`Syntax` には [`.from_path()`](https://rich.readthedocs.io/en/latest/reference/syntax.html#rich.syntax.Syntax.from_path) という、ファイルパスを与えればシンタックスハイライトを自動決定してくれるメソッドが用意されています。しかし、このメソッドを用いて実装したところ処理時間が
長かった（3行のファイルでもコマンド実行→標準出力まで一瞬ラグが出たくらい）ので、独自にシンタックスハイライト決定処理を作りました。

処理内容はざっくり以下の通りです。

1. 予め「拡張子→シンタックスハイライト」の対応辞書を用意しておく
1. 入力されたファイルパスから拡張子を識別し、用意した辞書を引いてシンタックスハイライトを決定する

拡張子とシンタックスハイライトの対応関係は、 [pygments](https://pygments.org) の [`LEXERS`](https://github.com/pygments/pygments/blob/master/pygments/lexers/_mapping.py) 変数の値を使いました（ pygments は rich がシンタックスハイライトをつけるために利用しているライブラリです）。また、辞書の更新方法も `LEXERS` 変数が定義されている [`_mapping.py` ](https://github.com/pygments/pygments/blob/master/pygments/lexers/_mapping.py) の実装を参考にしました。

実際にシンタックスハイライトの決定処理がどの程度速くなったかを簡単に実験してみました。実験は、 `.from_path()` を利用する方法と予め辞書を用意する方法に対し、様々な形式のファイルをそれぞれ1000回ずつ読み込ませることで行いました。

以下に、各ファイル形式で処理にかかった時間の基本統計量を可視化したものを示します。

`.from_path()` を利用する方法：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/489030/7559e6ca-baa3-9851-5624-3345d92a555f.png)

予め辞書を利用する方法：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/489030/a88023e0-be05-0233-9113-230877097cb4.png)

結果を見ると、予め辞書を用意したほうが10倍ほど速くなっていることが分かります。

実験コード：

https://github.com/richcat-dev/richcat-guess-lexer-dev/blob/main/notebook/speed_test.ipynb

### `.ipynb` ファイルのプレビュー

`.ipynb` ファイルのプレビューは richcat 開発当初から実装したかった機能でした。実装方法としては、既存ツール（ Jupyter の [nbconvert](https://nbconvert.readthedocs.io/en/latest/usage.html) ）を利用する方法と独自に `ipynb` ファイルの parser を作る方法の2つを考えました。しかし、それぞれ次の問題を抱えていました。

- 既存ツールを利用する　→　依存が増えてしまう（目的：richcat 1つで様々なファイルをプレビューできるようにしたい）
- 独自に parser を作る　→「車輪の再開発」、作れたとしても既存プログラムの大規模改修必至

そこで、既に Jupyter がインストールされている場合はそれを利用し、そうでない場合はシンタックスハイライトをつけるよう、処理を分岐させることにしました。これにより、 richcat を使うための依存を増やさずに既存ツールを利用できるようになりました。

## 今後やりたいこと

- Mac対応
- richcat できるファイル形式を増やす
    - ディレクトリも richcat できるようにする（rich の [Tree](https://github.com/Textualize/rich#rich-library) 機能を使うと良さそう）
    - ターミナル上で画像も richcat できるようにする（[libsixel](https://github.com/saitoha/libsixel/) というライブラリを利用してうまくやれないか検討中）
    - TensorBoard のログファイルを richcat できるようにする
    - Excel 、 ZIP 、etc...

ターミナル上で中身を確認しづらいファイル形式に焦点を当て、そのようなファイル形式でも確認しやすくしていきたい思っています。最終目標としては、「 richcat さえあればどんなファイル形式でもターミナル上で rich に cat できる」ところを目指しています。

## まとめ

この記事では、出力が"rich"な cat コマンド「richcat」について、その開発経緯や仕組みについて紹介しました。ぜひインストールして使ってみてください！なお、バグがあったり謎の実装をしていたりする個所があったりするかと思いますので、その場合はコメントやIssue等をいただけると助かります:bow:

ここまでお読みいただきありがとうございました！