- note -

Batch

6/10

batでtail   - Last modified:2014/2/15

■ 一応の完成?

tail -f … ファイルを監視して、更新分を出力し続けるオプション。 Linuxでのtail -fのような動きをするbatが欲しかったので、
試しに作ってみました。
一応/nと/fだけ対応してあります。
ただ、/fでの監視はビジーループとなってCPUパワーを食うので
使用する際は注意が必要です。

C01: tail.bat

@echo off
setlocal

set tailLine=10
set MAXLINE=999999999
set appendNum=9

if "%~1" == "/?" (
  echo tail version 1.0   2011/12/04
  echo usage: tail [/n xxx] [/f] [file]
  echo;
  echo    /n xxx  specify tail line
  echo    /f      file watching
  exit /b
)
:option_loop
if "%~1" == "/n" (
  set tailLine=%2
  shift
  shift
  goto option_loop
) else if "%~1" == "/f" (
  set optF=1
  shift
  goto option_loop
)

set forParams=delims^^=^^ eol^^= %%I in ('findstr /n .* %1') do

if "%~1" == "" (
  if not defined optF (
    set appendNum=0
    set n=0
    for /f %forParams% (
      set /a n+=1
      setlocal enabledelayedexpansion
      for /l %%J in (!n!,1,!n!) do (
        endlocal
        set l%%J=%%I
      )
    )
    call set line=%%n%%
    call :SubtractLine %tailLine%
    set /a line+=1
    call :SetLine
    set /a next+=1
    setlocal enabledelayedexpansion
    for /l %%I in (!line!,1,!n!) do (
      for /l %%J in (!offset!,1,!offset!) do echo;!l%%I:~%%J!
      call :NextLine
    )
    exit /b
  )
)

set prevStr=
set fileSize=0
set length=
set line=0
for /f "tokens=1 delims=:" %%I in ('findstr /n .* %1') do set line=%%I
call :SubtractLine %tailLine%
if defined optF goto tail_loop

if %line% == 0 (set nskip=) else set nskip=skip^^=%line%^^
call :PrintLine
echo;
exit /b

:tail_loop
set checkSize=%~z1
if %checkSize% == 0 (
  for /l %%I in (0,1,63) do (
    call set checkSize=%%~z1
    setlocal enabledelayedexpansion
    for /l %%J in (!checkSize!,1,!checkSize!) do (
      endlocal
      if not %%J == 0 goto tail_loop
    )
  )
)
if %fileSize% neq %checkSize% (
  call :UpdateFileSize
  call :PrintLine
)
goto tail_loop

rem ファイルの更新を検出
:UpdateFileSize
if %fileSize% gtr %checkSize% (set line=0&set prevStr=)
set fileSize=%checkSize%
if %line% == 0 (set nskip=) else set nskip=skip^^=%line%^^
call :GetLength prevStr
set length=%errorlevel%
if %length% == 0 set length=
exit /b

rem 文字数カウント
:GetLength
if not defined %1 exit /b 0
setlocal enabledelayedexpansion
for /l %%I in (0,1,4095) do if !%1:~%%I^,1! == !EMPTY! exit /b %%I
exit /b 4096

rem 指定行から表示
:PrintLine
call :SetLine
for /f %nskip% %forParams% (
  set prevStr=%%I
  if defined length (
    setlocal enabledelayedexpansion
    set /p _=!prevStr:~%length%!<nul
    endlocal
    set length=
  ) else (
    call :NextLine
    setlocal enabledelayedexpansion
    for /l %%J in (!offset!,1,!offset!) do echo;&set /p _=!prevStr:~%%J!<nul
    endlocal
  )
)
exit /b

rem 行番号設定
:SetLine
set /a next=line+1
call :GetLength next
set /a offset=%errorlevel%
call set next=%%MAXLINE:~0,%offset%%%
set /a offset+=1
if not defined length set length=%offset%
exit /b

rem 行番号更新
:NextLine
set /a line+=1
if %line% == %next% (
  set /a offset+=1
  set next=%next%%appendNum%
)
exit /b

rem 下限を0として行番号減算
:SubtractLine
set /a line-=%1
set /a "line=line*!(line&(1<<31))"
exit /b

■ 待ち受けていた罠

上に示したものを何度か動かして、
/nも/fもちゃんと機能するし空行はスキップしないし特殊記号も全て表示されているようだったので
取り敢えず満足 … 重いという点以外は。 取り敢えず満足していたのですが、ある日の実運用中、予想だにしていなかった
絶望的な事実を知ることとなりました。
それは、set /pが=に続く空白を全て無視してしまうということ。
おそらくプロンプト文ということで親切心から先頭の空白を消してくれる仕様になっているのでしょうが、
batプログラミング時においては余計なお世話でしかありません。
というのも、batで(変数展開後も含め)batに書かれている文字列をそのまま表示してくれるコマンドは
自分の知る限りechoとset /pしかありません。
そしてechoが改行付き出力であるのに対し、set /pは改行なしでechoしてくれる唯一のコマンドなのです。
また、tail(/f)を作るにあたっては
変化させない … ただしSJISに限る。
・入力を変化させない。
・1文字単位での更新を反映する。
・XP標準装備のコマンドだけで実現する。
の3点がクリアされていることを最低限の条件として考えていました。
batだから多少は妥協しても、などという甘い考えは許されません。
そんなこと言うくらいなら初めからC言語で作っています。
というわけで、set /pが「条件によって入力を変化させてしまう」という事実は深刻です。
これには非常に困りました。
改行なしecho … こんなときこそrundllexの出番なのだが、今回も残念ながら出番なし。 まず、set /p以外で改行なしechoできる方法がないか探しました。
たどり着いた可能性はtypeです。
typeは入力をそのままechoするだけで、最後に改行を付加するなどという
行儀の悪いことはしません。
しかしtypeは標準入力からの入力に未対応です。
一時的にファイルへ吐き出してからtypeで読み込めば出来なくはなさそうですが、苦しいと言わざるを得ません。
というかそもそも、ファイルに対して改行なしで出力する方法がないわけで、その方法を探しているのですから
かなりの呆け具合です。
ならばと次に思い至ったのは、「set /pで出力する前に空白だけ別コマンドで出力しておく」作戦。
まず空白1つのみが書かれている1byteのファイルを用意しておき、出力時に先頭の空白数を数え上げ、
その回数だけtypeでの空白ファイル読み込みをループで回せばいけるのでは? と考えました。
では、その「空白1つのみが書かれているファイル」はどうやって用意すればいいでしょうか。
例えばcopyやxcopyあたりに「指定のバイト数だけコピーする」とか、echoに「指定のバイト数だけ出力」とかいう
オプションがあればよかったのですが、もちろんそんな「高度な機能」があるはずもなく、
typeはさっきと同じ理由で使えず、堂々巡りから抜け出せません。
ここまで考えて、種々のコマンドをどうにか捏ね繰り回してなんとかする、ということはもう諦めました。
といっても、上で挙げた3つの条件を諦めたわけではありません。
batの禁じ手 … ちなみにこの方法はC言語でも使える。 取った手段は、batの禁じ手です。
はっきり言って、この方法はかなり好きではないので使いたくありませんでしたが、背に腹は代えられません。
制御コードを埋め込みます。

C02: tail.bat:110~132

rem 指定行から表示
:PrintLine
call :SetLine
for /f %nskip% %forParams% (
  set prevStr=%%I
  set dummy=_
  if defined length (
    setlocal enabledelayedexpansion
    if "!prevStr:~%length%!" == "" set dummy=
    set /p _=!dummy! !prevStr:~%length%!<nul
    endlocal
    set length=
  ) else (
    call :NextLine
    setlocal enabledelayedexpansion
    for /l %%J in (!offset!,1,!offset!) do (
      if "!prevStr:~%%J!" == "" set dummy=
      echo;&set /p _=!dummy! !prevStr:~%%J!<nul
    )
    endlocal
  )
)
exit /b
赤い部分に制御コードのBS(0x08)を埋め込んでみました。
これにより、set /pが出力を試みる文字列の先頭は_かBSのどちらかになるので、
空白がスキップされることはなくなります。
dummy変数を使用してdummy文字(アンダーバー)の出力ON/OFFを切り替えているのは、
空行出力時はBSが効かずにdummy文字がそのまま出力されてしまっていたためです。
というわけで、上記修正を施した最終版はこちら → tail.bat
最終版 … このbatで/f指定時にリダイレクトするとエライことになってしまうのは秘密。
あー、疲れた。

おまけ

その後の調べで、さらに嫌なことが続々と判明してきました。
まず、set /pの動きはWinXPとWin7で違うようです。
上で長々と書いたset /pが空白をスキップして困ったというのはWin7で実行したときの話でした。
ところが同じものをWinXPで実行してみると、空白をスキップする気配なんかまるで皆無ではないですか。
何故そこだけ微妙に違うのか、set /pだけの問題なのか、他のOSではどうなのか……謎は深まるばかりです。
そしてもう一つ。
set /pがechoしない文字は空白だけではなく、
ダブルクォーテーションとイコールもNGでした。
そうすると、結局コマンドでの対処は無理だったような気がするので、
最終的にBS埋め込みでの対応を選択していたことは実に皮肉なものです。