端末のパイプ先に特定の出力だけ渡す方法


経緯

CLIツールを作っていたときに、コマンドの標準出力のうち、
パイプ先のコマンドに渡したい出力と渡したくない出力があるケースがでてきました。

以下のようなツールです。

何らかの処理を行い、プログレスバー風のアニメーションをプロンプトに表示し、
処理結果のファイル名を最後に出力するコマンドです。

コードは以下のような感じです。
Nimでの実装です。

import os, strutils, strformat

for i in 1..60:
  # カーソルを1行上に移動し、行を削除するANSIエスケープシーケンス
  echo "\x1b[1A\x1b[K\x1b[1A"

  # 進捗の分数
  let prog = &"[ {i:>2}/60 ]"
  # 処理済みのバー
  let leftBar = "\x1b[44m" & " ".repeat(i).join() & "\x1b[m"
  # 空白のバー
  let rightBar = " ".repeat(60 - i).join()
  echo &"{prog} [ {leftBar}{rightBar} ]"
  sleep 25

# 処理結果を格納したファイルパス
echo "/var/tmp/result.txt"

渡したくないのはアニメーションをしているプログレスバーの部分です。
最後のファイル名だけパイプ先に渡して、catなりvimなりで開きたかったんです。

これをそのままlessするとどうなるか?
以下のようになります。

こうなります。悲惨です。
プログレスバーの出力と、カーソル移動、カーソル行の削除のANSIエスケープシーケンスまでlessが補足しています。

これを回避する方法を調べて、解決したことを書きます。

類似ツールの調査

必要な出力と不要な出力を分けてパイプ先に渡しているツールとしてpecoが思い浮かびました。
なので、pecoのソースを調べることにしました。

pecoのソースを読んだ所、pecoのUI部分はtermboxが全部引き受けていることがわかりました。
termboxで軽くUIを作ってlessしてみたところ、termboxのUI出力はパイプ先に渡さないことがわかりました。
termboxのソースを読んだところ、以下の部分がその理由でした。

/dev/tty を開いています。
tty について全然把握していなかったので、ttyについて調べました。
以下のQiitaの記事がとてもわかりやすかったです。

Qiita - ttyとかptsとかについて確認してみる

実装

前述の実装を参考に以下のように修正しました。

--- mycmd.nim   2019-11-05 21:16:47.623075601 +0900
+++ mycmd2.nim  2019-11-05 21:20:30.929732374 +0900
@@ -1,5 +1,15 @@
 import os, strutils, strformat

+var
+  tty = open("/dev/tty", fmReadWrite)
+  oldStdin = stdin
+  oldStdout = stdout
+  oldStderr = stderr
+
+stdin = tty
+stdout = tty
+stderr = tty
+
 for i in 1..60:
   echo "\x1b[1A\x1b[K\x1b[1A"

@@ -9,4 +19,9 @@
   echo &"{prog} [ {leftBar}{rightBar} ]"
   sleep 25

+tty.close()
+stdin = oldStdin
+stdout = oldStdout
+stderr = oldStderr
+
 echo "/var/tmp/result.txt"

tty で仮想端末を開き、stdin,stdout,stderrを上書きします。
echoは上書きされたstdoutのほうに出力します。
一連の処理が終わったらttyを閉じてしまい、上書き前のstdin,stdout,stderrで元に戻します。

このように変更を加えたプログラムmycmd2を実行してみます。
結果がわかりやすいようにnlを使います。

最後の出力結果のみ、パイプ先のnlが処理するようにできました。
これで特定の出力だけパイプ先に渡せそうです。

実装例

まだ作りかけなのですが、今回得た知見を利用して
Nimでディレクトリツリーを表示するコマンドを作ってます。

選択したファイルを最後に出力するので、あとはパイプ先でよしなに使ってくれ、というツールにしようかと。

以上です。