lessプリプロセッサ解説

# lessプリプロセッサ

## 要約

lessを便利にするには:

* Debian系OSなら `eval $(lesspipe)` する
* RHEL系OSなら `export LESSOPEN="||/usr/bin/lesspipe.sh %s"` する
* OS標準のlesspipeが使えない/不満なら[wofr06/lesspipe](https://github.com/wofr06/lesspipe)で `eval $(lesspipe.sh)` する
* `~/.lessfilter` でOS標準プリプロセッサを拡張できる

## 入力プリプロセッサ

ファイルページャーの [less(1)](https://www.greenwoodsoftware.com/less/) にバイナリファイルを渡すと、テキスト形式にデコードして表示してくれることがあることは多くの方がご存じだろう。例えば、tarballや圧縮ファイルを明示的に展開せずに、直接lessで表示できる。

```console
$ less -F some.tar.gz
-rw-r--r-- root/root         0 2023-12-09 15:35 a.txt
-rw-r--r-- root/root         0 2023-12-09 15:35 b.txt

$ # 手動の場合 (GNU tarなど実装によってはtarだけでも可能)
$ gzip -d -c some.tar.gz | tar -tv | less -F
-rw-r--r-- root/root         0 2023-12-09 15:35 a.txt
-rw-r--r-- root/root         0 2023-12-09 15:35 b.txt
```

しかしながら、less自体に特定のバイナリ形式をデコードする機能が実装されているわけではない。lessには外部ツールの力を借りてデータを前処理する機能が備わっており、このツールがバイナリデータをテキスト形式にデコードしてlessに渡し、lessはただそれを表示しているだけである。 この外部ツールは「入力プリプロセッサ」と呼ばれる (以下、単に「プリプロセッサ」)。プリプロセッサの機能を活用すれば、論理的にはどんなデータでもlessで表示できる。バイナリだけではなく、テキストからテキストへの変換も可能だ ([ソースコードのシンタックスハイライト](https://nippondanji.blogspot.com/2011/11/less.html)はプリプロセッサのよく知られた活用例だろう)。 Ubuntuなど一部のOSでは、ユーザーが意識せずともプリプロセッサが利用できるようにデフォルトで設定されている場合もある。

プリプロセッサの実装はOSによって異なり、Debian系OSの[lesspipe](https://sources.debian.org/src/less/590-2/debian/lesspipe/) や、RHEL系OSの[lesspipe.sh](https://git.rockylinux.org/staging/rpms/less/-/blob/r9/SOURCES/lesspipe.sh) (リンクはRocky Linux 9) を使うことが多いだろう。また、Wolfgang Friebel氏による[lesspipe.sh](https://github.com/wofr06/lesspipe) (以下「wofr06/lesspipe」) もよく知られており、[Arch Linuxではこれが提供](https://archlinux.org/packages/extra/any/lesspipe/)されている。多くの実装で lesspipe という名称が使われていることからもわかるとおり、lessプリプロセッサ一般をlesspipeと呼ぶこともある (openSUSEのlessopen.shなどlesspipeという名称を使わないケースもある)。macOS等プリプロセッサが提供されていない環境も存在する。必要に応じてプリプロセッサは自前で実装することも可能である (実装例は[後述](#プリプロセッサの自作))。


## プリプロセッサの利用方法

lessは、環境変数 `LESSOPEN` に指定されたコマンドをプリプロセッサとして呼び出す。`LESSOPEN`に記載された内容はシェルを通じて呼ばれるのでシェルスクリプトを書くこともできる。

通常lessがプリプロセッサを呼び出すのは、`less some.tar.gz` のように、lessの引数として具体的なファイルパスが指定された時だ。`cat some.tar.gz | less` のようにパイプを通じてlessにデータを渡した場合、プリプロセッサは呼び出されない。※利用は少ないが、パイプ経由でのless起動でもプリプロセッサを呼び出す方法があり、[後述](#制御フラグ)する。

Debian系のUbuntu 22.04, RHEL系のRocky Linux 9.3 を確認すると、`LESSOPEN` の初期値は以下のようになっている。

Ubuntu:
```console
$ echo $LESSOPEN
|lesspipe %s
```

Rocky Linux:
```console
$ echo $LESSOPEN
||/usr/bin/lesspipe.sh %s
```

### LESSOPENの定義方法

環境変数 `LESSOPEN` は、以下のフォーマットとなる。

```sh
LESSOPEN="{制御フラグ} {コマンド名} %s"
```

#### プレースホルダー %s

* `%s` を必ず1個含めなければならない (0個でも2個以上でも許可されない)。入力ファイルパスに置換されるプレースホルダーである。プリプロセッサコマンドへの引数として使う。

    ```console
    $ # %sを2個指定した場合のエラー例
    $ LESSOPEN='|lesspipe %s %s' less some.tar.gz
    LESSOPEN ignored: must contain exactly one %s
    "some.tar.gz" may be a binary file.  See it anyway?
    ```

#### 制御フラグ

制御フラグは、less本体への指令として機能し、less本体によるプリプロセッサの実行方法、プリプロセッサの出力結果の解釈方法を制御するために使われる。

* 典型的には、先頭文字は縦棒 (パイプ文字) とする。これは、プリプロセッサの処理結果はパイプを通じてless本体へ渡すという意味になる。すなわち、プリプロセッサの標準出力をless本体が読み取るよう、lessへの指令となる。一見、`|lesspipe %s` は、まるで元の入力データがパイプを通じてプリプロセッサの`lesspipe` に渡されるかのように見えるが、そうではない。セマンティクスはむしろ逆で、`lesspipe %s | less` の意味である。

* RHEL系の例のように先頭がダブルパイプ (`||`) の場合は、プリプロセッサの出力が空(empty)だったときに、プリプロセッサの終了ステータスが成功なら、less本体は空データを実際の出力結果として表示する。エラーステータス(非ゼロ)ならプリプロセッサで処理できなかったものとして、less本体で処理しようとする。これが意味を持つケース例は、プリプロセッサで圧縮ファイルを伸長した結果その中身が空データだったという場合等だ。この場合、ダブルパイプならlessには空データが表示される (=正しく何も表示されない)。一方で、Debian系のようにシングルパイプで始まる場合、プリプロセッサの出力が空だった場合、less本体は、プリプロセッサの成功/失敗に関係なく、プリプロセッサで処理できなかったものとして、そのバイナリファイルの表示を試みる。

    以下は、RHEL系 (Rocky Linux 9)とDebian系 (Ubuntu 22.04)の実際の比較例である。

    ```console
    $ # RHEL系 (ダブルパイプ)
    $ echo hello | gzip -c > hello.gz # 中身がhelloの圧縮ファイルを作成
    $ echo -n | gzip -c > empty.gz    # 中身が空の圧縮ファイルを作成
    $
    $ less -fF hello.gz  # helloがテキストで表示される
    hello
    $ less -fF empty.gz  # 期待通り空データが表示される
    $
    ```

    ```console
    $ # Debian系 (シングルパイプ) (圧縮ファイルはRHEL系と同じものを作成済みとする)
    $ less -fF hello.gz  # helloがテキストで表示される
    hello
    $ less -fF empty.gz  # 生のバイナリのgzipファイルを表示しようとする
    ^_<8B>^H^@^@^@^@^@^@^C^C^@^@^@^@^@^@^@^@^@
    ```

* 先に、`cat some.tar.gz | less` のようにパイプ経由でlessを起動した場合プリプロセッサは呼ばれないと説明したが、プリプロセッサコマンド名の前にダッシュ (ハイフン) 記号がある場合(`|-lesspipe %s` や、`||-lesspipe.sh %s`等)、パイプ経由でもプリプロセッサを起動できる。この場合、プリプロセッサの第一引数は`-`となる。ただし、Debian系、RHEL系どちらのプリプロセッサも、ダッシュ付き起動に対応していない (wofr06/lesspipeは対応)。

* 利用頻度は少ないが、先頭にパイプ文字を置かずにいきなりプリプロセッサ名で始めることもできる。この場合プリプロセッサからless本体へのデータ渡しは、パイプを使わずにファイルを通じて行われるようになる。ユーザーが指定した入力ファイルを、プリプロセッサが別のファイルに置き換えてless本体に渡すようなイメージである。本稿では、この種のプリプロセッサを「ファイル置換プリプロセッサ」と呼び、その対比として、これまで説明した通常のプリプロセッサを「パイプ系プリプロセッサ」と呼ぶ (それぞれ本稿独自の便宜的な用語である点に注意)。

    ファイル置換プリプロセッサについて実例をもとに説明すると、Debian系OSでは `/usr/bin/lessfile` が提供されている。これは、プリプロセッサの処理結果を `/tmp/lessfXXXXXX` のような一時ファイルに出力し、less本体は`/tmp/lessfXXXXXX`の内容をページャーで表示する。プリプロセッサからless本体への一時ファイルパスの伝達は、プリプロセッサがパスを標準出力に書き出すことで行われる。less本体はこれを読み取り、それをプリプロセッサによ処理結果ファイルとして、その内容をユーザーに表示する。なお、Debian系OSの `lessfile` と `lesspipe` の実体は同一であり、実行コマンド名に基づいてスクリプト内部で処理を分岐している。RHEL系OSのプリプロセッサはファイル置換プリプロセッサに対応していない。

    ファイル置換プリプロセッサの利用頻度が少ない理由として、わざわざ別ファイルの生成が必要になること、lessの処理が終わればファイル削除の後処理が必要になること、等が挙げられる。一時ファイルのクリーンアップを行うスクリプトは、ポストプロセッサと呼ばれる。less本体は、環境変数 `LESSCLOSE` に設定されたコマンドをポストプロセッサとして実行する。定義例は、`LESSCLOSE="/usr/bin/lessfile %s %s"` である。第一引き数はユーザー指定のオリジナル入力ファイルパス、第二引数は一時ファイルパスである。Debian系の `lessfile`では、 `eval $(lessfile)` のようにして、プリプロセッサ、ポストプロセッサ両設定が同時にできる。

    ```console
    $ lessfile
    export LESSOPEN="/usr/bin/lessfile %s";
    export LESSCLOSE="/usr/bin/lessfile %s %s";
    $ eval $(lessfile)
    $ env | grep -E 'LESS(OPEN|CLOSE)'
    LESSCLOSE=/usr/bin/lessfile %s %s
    LESSOPEN=/usr/bin/lessfile %s
    ```

    ※ファイル置換プリプロセッサはあまり利用されないので、これ以降特に言及が無い限り、プリプロセッサは `lesspipe`のようなパイプ系プリプロセッサを前提として説明する。

制御フラグをまとめると以下のようになる。

| 制御フラグ   | 記述例           | 意味                                                         |
|---------|---------------|------------------------------------------------------------|
| パイプ無し   | command %s    | ファイル置換プリプロセッサを起動。                                          |
| シングルパイプ | \|command %s   | パイプ系プリプロセッサを起動。空出力の場合less本体で処理。                            |
| ダブルパイプ  | \|\|command %s  | パイプ系プリプロセッサを起動。空出力を正常な出力として処理 (終了ステータス非ゼロの場合less本体で処理)。 |
| ダッシュ    | \|\|-command %s | オリジナルデータがパイプ経由でlessに渡される場合もプリプロセッサを起動。                     |



## less本体によるプリプロセッサ呼び出し方法

less本体は、[popen(3)](https://www.man7.org/linux/man-pages/man3/popen.3.html) を用いてシェル (sh) 経由でプリプロセッサを呼び出す。このとき環境変数 `SHELL` が設定されていると、プリプロセッサコマンドを `${SHELL} -c` でラップした上でpopenする。例えば、環境変数`SHELL`に/bin/zshが設定されていた場合、popenが実行するshも加えると `sh -c /bin/zsh -c lesspipe foo` のようなexecとなる。図にするとプリプロセッサ (lesspipe) までのプロセス起動は以下のようになる。シェルが2段階で起動されるのはいかにも非効率である。

```mermaid
graph LR
    less --> sh --> zsh --> lesspipe
```

## プリプロセッサ実装比較

どのプリプロセッサ実装も概ね以下のような処理を行う。

* ユーザー定義フィルター ([後述](#ユーザー定義フィルターによるプリプロセッサ拡張)) があれば呼び出す
* 入力ファイル形式に応じて外部ツール (`tar`, `gzip` 等) でデコードして出力する
* 非対応のファイル形式はless本体に処理を委ねる

以下は、Debian系, RHEL系, wofr06/lesspipeの各実装の比較である。
総じてwofr06/lesspipeが最も強力である (wofr06/lesspipeは本機能のデコード処理も強力なので、機会があれば別記事で個別に取り上げたい)。

| 項目                       | Debian系           | RHLE系                | wofr06/lesspipe |
|--------------------------|-------------------|----------------------|-----------------|
| パス                       | /usr/bin/lesspipe | /usr/bin/lesspipe.sh | 任意/lesspipe.sh  |
| 空データを表示できる (ダブルパイプ対応)    | no                | YES                  | YES             |
| ファイル置換プリプロセッサ対応          | YES               | no                   | no              |
| 元データをパイプから読める (ハイフン対応)   | no                | no                   | YES             |
| `eval lesspipe` で環境設定できる | YES               | no                   | YES             |
| ユーザー定義フィルター対応            | YES               | YES                  | YES             |

あくまで補足情報だが、一点注意点を述べておく。Debian系実装では、入力ファイル形式がプリプロセッサのサポート対象であり、かつその形式をデコードする外部ツールがインストールされていない場合、以下のようにエラーメッセージがlessの表示結果となってしまう。

```
$ echo > some.doc
$ less -F some.doc 2>/dev/null
No catdoc available
$ echo $?
0
```

上記は、プリプロセッサが`.doc`という拡張子を持つファイルを処理しようとしたが、`catdoc` コマンドがインストールされていないので、プリプロセッサがエラーメッセージを ***標準出力*** に出力し、結果的にlessはそのエラーメッセージをファイルコンテンツとして表示している。これは標準エラー出力ではない。しかもlessの終了ステータスは成功である。この挙動は、意図的なものかもしれないが、ユーザーがlessの表示結果を実際のファイルコンテンツに基づくものと誤認識してしまうリスクがある。

RHEL系でも同様の問題が一部の拡張子 (`.png`等)で発生するが、一部だけである。多くの場合、プリプロセッサが処理できない場合、サイレントにless本体に処理を委ねる。RHEL系はエラーハンドリングに一貫性が無いという問題があるとも言える。立場によっては、Debian系よりも質が悪いと考える向きもあるだろう。

## OS毎のシェル設定ファイル構成

はじめの方で、一部のOSではユーザーが意識せずともプリプロセッサが利用できるようにデフォルトで設定されていると述べた。これは、環境変数`LESSOPEN`がデフォルトで設定されているということだが、この仕組みについて、Debian系としてUbuntu 22.04, RHEL系としてRocky Linux 9.3を例に、各OSの構成を確認する。

### Ubuntu (Debian系)

`bash` パッケージにより `/etc/skel/.bashrc` がインストールされており、その中に以下の定義がある。
これが、各ユーザーの `~/.bashrc` 初期値として展開され、bashを使う限りでデフォルトで利用できるように構成されている。

```sh
# make less more friendly for non-text input files, see lesspipe(1)
[ -x /usr/bin/lesspipe ] && eval "$(SHELL=/bin/sh lesspipe)"
```

実は、UbuntuのアップストリームであるDebian本体は、[上記の行がデフォルトでコメントアウトされている](https://sources.debian.org/src/bash/5.2.15-2/debian/skel.bashrc/#L31)ので、初期状態では利用できない。

### Rocky Linux (RHEL系)

`less` パッケージにより、`/etc/profile.d/less.sh` がインストールされており、その中に以下の定義がある。
`/etc/profile.d/less.sh` はシステムワイドのシェル設定ファイルとして各ユーザー共通にその定義が適用される。

```sh
if [ -z "$LESSOPEN" ] && [ -x /usr/bin/lesspipe.sh ]; then
    # The '||' here is intentional, see rhbz#1254837.
    export LESSOPEN="||/usr/bin/lesspipe.sh %s"
fi
```


## ユーザー定義フィルターによるプリプロセッサ拡張

OS標準のプリプロセッサ処理では満足できない場合でも、プリプロセッサのコードに変更を加えずに、ユーザーがプラグイン的にプリプロセッサを拡張することができる。このプラグインはユーザー定義フィルターと呼ばれる。

ユーザー定義フィルターによる拡張は、あくまでプリプロセッサが独自に備える機能であり、less本体とは全く関係ない。
幸いにして、Debian系、RHEL系、wofr06/lesspipeともにユーザー定義フィルターをサポートし、しかも同じように取り扱うので、ひとつのフィルター実装を両OS間で流用できる。

まずは、ユーザー定義フィルターのサンプル実装を見てみよう。
以下は、ELFバイナリがlessに渡された場合に `readelf` でELFヘッダをless表示できるようにする拡張例である。
`~/.lessfilter` がユーザー定義フィルターの実体だ。
それ以外のファイルは、OS標準プリプロセッサが利用される。

```console
$ # ユーザー定義フィルターの実装例
$ cat ~/.lessfilter
#!/bin/bash
if [[ $(file -b "$1") =~ ^ELF ]]; then
  readelf -h "$1"
  exit 0
fi
exit 1

$ # ELFバイナリをlessで表示する
$ less -F /usr/bin/less
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              DYN (Position-Independent Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x8890
  Start of program headers:          64 (bytes into file)
  Start of section headers:          197128 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         13
  Size of section headers:           64 (bytes)
  Number of section headers:         30
  Section header string table index: 29
```

### ユーザー定義フィルターが実装すべき動作仕様

以下は各プリプロセッサ実装に共通するユーザー定義フィルター仕様である。

* ユーザー定義フィルターのパスは`~/.lessfilter`とし、実行可能ファイルとする
* 第一引数に元の入力ファイルパスを受け取る
* 処理結果を標準出力に書き出し、ステータス 0で終了する -> 標準出力の内容がlessに表示される
* 入力ファイルを処理できない (形式が未知など) 場合、非0で終了する -> その後の処理をプリプロセッサに委ねる

### ユーザー定義フィルターではできないこと

※以下はプリプロセッサ実装に依存する内容であり、お使いのプリプロセッサには当てはまらない可能性がある点にご留意ください。

* 存在しない入力ファイルパスのハンドリング (Debian系, RHEL系では、ファイルが存在しない場合ユーザー定義フィルターが呼ばれない) 
    * 例えば `less https://api.example.com` をユーザー定義フィルターで `curl` で処理する等はできない
    * wofr06/lesspipeでは、ファイル存在確認の前にユーザー定義フィルターを呼び出すので未存在ファイルもハンドリングできる
* プリプロセッサの処理結果をさらに処理する

## プリプロセッサの自作

以下では、OS標準のプリプロセッサを `lesspipe` とし、カスタムのプリプロセッサスクリプトを `my-lesspipe` とする。

```coonsole
export LESSOPEN='||my-lesspipe %s'
```

### HTTPリソースが指定されたらcurlで取得してjqで整形

```shell
if [[ "$1" =~ https?:// ]]; then
  curl -s "$1" \
    | jq -Rr '. as $line | fromjson? // $line' # jsonなら整形
  exit 0
fi
```

### ファイルが見つからない場合にSLを走らせる

存在しないファイルを開こうとしてまうなんて、きっとお疲れなのでしょう。
走るSLを眺めながら一休みしましょう。

```shell
#!/bin/bash
if [[ ! -f "$1" ]]; then
  # slを標準エラー出力にリダイレクトすることでエスケープシーケンスをlessに表示させない
  sl >&2
  exit 1
fi
# 正常時はOS標準のプリプロセッサを呼び出す
lesspipe "$1"
```

実行例:

![sl](https://raw.githubusercontent.com/jmatsuzawa/qiita-posts/main/less-preprocessor/less-sl.svg)

### less爆弾

多くの人が考えるだろうが、lessプリプロセッサあるいはユーザー定義フィルターの中でlessを起動すると一体どうなるか?
当然、lessがlessを呼び、そのlessがまたlessを呼び...と再帰呼び出しが発生する。
試してみたい方は、せっかくなので以下のようにlessをバックグランドで呼び出してみるとよいだろう。

```console
$ LESSOPEN='|less %s &' less something
```

もちろん、プリプロセッサ内でlessを起動しなければ再帰呼び出しは発生しない。そして意図的にlessを実行するユースケースはほとんどないだろう。しかしながら、自分が呼び出したコマンドが間接的にlessを呼んでいないか、呼んでいたとして再帰が発生しないか、把握できているだろうか。問題の有無はさておき、内部的にlessを呼び出す可能性のあるコマンドはたくさんある。有名な例としては、`git`, `systemctl`, `aws` ([AWS CLI](https://aws.amazon.com/cli/)), `man` 等だ。

意図せずプリプロセッサ内でlessが呼ばれる事故が発生しても、再帰呼び出しを防ぐ方法がある。プリプロセッサ内で、`LESS="${LESS} L"`, または `LESSSECURE=1` の環境変数を定義しておけばよい。

* `LESS="${LESS} L"` 

    環境変数`LESS` は、less本体が自動的に有効にするオプションを指定できる。プリプロセッサを無効化するオプション `-L` を設定しておけば、サードパーティコマンドがlessを実行してもプリプロセッサは起動されない。(FYI: `git diff`等によるlessでのカラー表示は`LESS`変数をうまく活用している)

* `LESSSECURE=1`

    この環境変数が設定されている場合、lessはセキュアモードで起動する。セキュアモードのlessはプリプロセッサ実行を禁止する (セキュアモードは、`-L` オプションよりも強固で、プリプロセッサ以外にもless内部でのコマンド実行やファイル編集等も禁止される)。

言うまでもないが、再帰防止用途としては、上記の環境変数設定はプリプロセッサまたはユーザー定義フィルター内でガード的に行うこと。`.bashrc` 等のシェル設定ファイルで一括設定してしまうと、プリプロセッサが全く起動できなくなるので注意。

上記は、ほんの少しだがカスタムプリプロセッサの例である。紹介した内容をまとめると以下のようになる:

```sh
#!/bin/bash
# less 再帰呼び出し防止
export LESS="${LESS} L"

os_lesspipe() {
  if [[ -x $(which lesspipe.sh) ]]; then
    lesspipe.sh "$1"
  elif  [[ -x $(which lesspipe) ]]; then
    lesspipe "$1"
  else
    return 1
  fi
}

# HTTPリソースならcurlで取得
if [[ "$1" =~ https?:// ]]; then
  curl -s "$1" \
    | jq -Rr '. as $line | fromjson? // $line'
  exit 0
fi

# ファイルが存在しない場合SLを走らせる
if [[ ! -f "$1" ]]; then
  sl >&2
  exit 1
fi

# 上記以外はOS標準のプリプロセッサに委ねる
os_lesspipe "$1"
```

## まとめ

[要約](#要約) セクション参照。

## 参考資料

* [less(1) man page](https://www.man7.org/linux/man-pages/man1/less.1.html)
* [lesspipe(1) man page](https://manpages.debian.org/bookworm/less/lesspipe.1.en.html)
* Carl Albing, JP Vossen, Cameron Newham (2007). bash Cookbook [Section 8.15 Doing More with less](https://www.oreilly.com/library/view/bash-cookbook/0596526784/ch08s15.html). O'Reilly Media, Inc.