秒単位でタイムスタンプを比較してビルドするバッチファイルをささっと作る（makeが欲しい）

# はじめに

あるとき出張先でちょっとしたプログラムを作ることになった。出張用のノートＰＣのため，開発用ツールはインストールされていないし，ＩＴ部門に申請する暇もないので，**Windows** に標準的に組み込まれているメモ帳と **C#** コンパイラで開発せざるを得なかった。当然のことながら便利なビルド自動化ツールもないので，十数個あるソースファイルのうち一つだけ弄っては全ビルドを実行するという無駄な作業を行っていた。

$\color{red}{\Huge\textsf{嗚呼 make が欲しい。}}$

ちなみに筆者は **msbuild** の使い方が分からないというか，正しくはプロジェクトファイルの書き方が分からない。たぶん人が手で書くようなものではないと思っている。

# お品書き（仕様案）

ということで **make** のようにビルド自動化を行ってくれるバッチファイルを作ることにした。自動化といっても下記のような単純作業である。

- ターゲットファイル（実行ファイル，**EXE** ファイルのこと）が存在しないとき，ビルドコマンドを実行する
- ターゲットファイルが存在するとき，ターゲットファイルとソースファイル（**CS** ファイルのこと，複数）のタイムスタンプを比較し，ソースファイルのうち一つでもターゲットファイルより新しければビルドコマンドを実行する
- ターゲットファイルが全てのソースファイルより新しければ何もしない

ターゲットファイルは複数あるので，これらを実行するサブルーチン **AutoBuild** としてまとめる。サブルーチン **AutoBuild** は以下の３つの引数を取るものとする。

<dl>
<dt>ターゲットファイル名</dt>
<dd>ビルド後に生成される実行ファイル名とする。拡張子（この場合は <b>.EXE</b>）を含む。</dd>
<dt>ソースファイル名</dt>
<dd>拡張子（この場合は <b>.CS</b>）を含む。複数ファイルを指定するときはスペースで区切り，二重引用符で囲むこと。スペースを含むパス名には対応しない。
</dd>
<dt>ビルドコマンド</dt>
<dd>スペースを含むときは二重引用符で囲むこと。</dd>
</dl>

呼び出し時のイメージを以下に示す。`TEST0.CS` ～ `TEST9.CS` まで10個のテストコードがあり，それらから実行ファイル `TEST0.EXE` ～ `TEST9.EXE` を作る。`COMMON.CS` は共用モジュールである。

```:make.cmd（こんな感じで呼び出したい）
@echo off
setlocal enabledelayedexpansion
for %%I in ( TEST0 TEST001 TEST2 TEST3 TEST4 TEST5 TEST6 TEST7 TEST8 TEST9 ) do (
  set  Target=%%I.EXE
  set  Source=%%I.CS COMMON.CS
  set  Option=-r:Microsoft.VisualBasic.DLL
  set Command=csc -nologo -w:4 -o -out:!Target! !Source! !Option!
  call :AutoBuild !Target! "!Source!" "!Command!"
)
```

# 課題）秒の単位を得ることができない

バッチファイルにおいて，ファイルの更新日時は簡単に得られる。`CALL` コマンドによって呼び出されるバッチパラメータ `%1` あるいは `FOR` コマンドのループ変数 `%%I` とおくと，`%~t1` もしくは `%%~tI` のように `~t` という修飾子を付加すればファイルの**更新日時**を得ることができる。具体的には以下のように年月日に加えて24時間制の時分を得ることができるのだが，残念ながら**秒**の単位を取得することができない。

```
c:\qiita>@for %I in ( test0.* ) do @echo %~tI %I
2024/02/01 17:30 test0.exe
2024/02/01 17:27 test0.cs	
```

もちろんこれでもたいていの場合は問題ないが，コード修正～ビルド～テスト実行を１分以内に繰り返した場合，ソースファイルとビルドした実行ファイルの新旧判別ができないのだ。

※他に `dir` コマンドを用いてもファイルの更新日時を取得することができるが，秒の単位を得ることはできない。

# 案その１）forfiles コマンドを使う

**Windows** に標準搭載されているコマンドの中で解決しようとすれば，`forfiles` コマンドを使う方法が知られている。ただし，`forfiles` コマンドの動作は何故か**もっさり**としていて受け入れ難い。もう少し，**きびきび**と動かないものだろうか。

```
c:\qiita>forfiles /M test0.* /c "cmd /c echo @fdate @ftime @file"

2024/02/01 17:30:39 "test0.exe"
2024/02/01 17:27:12 "test0.cs"
```

# 案その２）PowerShell を使う

**PowerShell** を使えば何でもできる。下記の場合は秒単位での表示に留めたが，ミリ秒単位で得ることも可能である。※参考文献参照のこと。

```
c:\qiita>powershell "Get-ChildItem test0.* | Select Name, LastWriteTime"

Name      LastWriteTime
----      -------------
test0.exe 2024/02/01 17:30:39
test0.cs  2024/02/01 17:27:12
```

加えて，タイムスタンプでソートすることも楽勝である。

```
c:\qiia>powershell "Get-ChildItem test0.* | Sort-Object LastWriteTime | Select Name"

Name
----
test0.cs
test0.exe
```

ただ，バッチファイルから **PowerShell** スクリプトを呼び出すと動作が**もっさり**してしまうのが難点だ。ファイルのタイムスタンプを得るためだけに **PowerShell** を使うのは効率が悪い。**PowerShell** を使うのであればビルド自動化まで全部 **PowerShell** スクリプトで書くべきだろう。

# 案その３）dir コマンド を使う

**PowerShell** を使っていて閃いた。コマンドプロンプト上で使えるビルトインコマンドではファイルの更新日時を**秒**単位で得ることはできないが，`dir` コマンドを用いればファイルを**秒**単位で比較・ソートすることはできる。

```
c:\qiita>dir /B /OD /TW test0.*
test0.cs
test0.exe
```

最新のファイル名を取得したければ，`for` コマンドを用いて最終行のみ得ればよい。
※スキップする行数（＝ソースファイル数）を事前に把握しておく必要がある。

```
c:\qiita>@for /F "skip=1" %I in ( 'dir /B /OD /TW test0.*' ) do @echo %I
test0.exe
```

# コード実装

ということで，案その３を採用することにした。

### 一次チェック

最初から `dir` コマンドの出力を用いると**もっさり**とした動作になってしまうので，一次チェックとしてバッチパラメータの修飾子 `%~t1` を用いてファイルの更新日時を比較する。ソースファイルのうちターゲットファイルよりも新しいものが一つでもあればビルド実行 `ExecBuild=1` である。更新日時が同時分で新旧の判別がつかないものは `Ambiguous=1` として二次チェックに進む。なお，一次チェックの中でソースファイル数をカウントしておく。

### 二次チェック

二次チェックでは `dir` コマンドの出力を用いる。ターゲットファイルとソースファイルの中で最新のファイル名を得て，これがターゲットファイル名と一致すればビルドしない。


### 実装コード

以上より，実装コードを以下に示す。

```:自動ビルド部
rem ----------------------------------------------------------------------------
rem 自動ビルド
rem
rem 引数(1)　ターゲットファイル
rem 引数(2)　ソースファイル（複数指定の場合は二重引用符で囲むこと）
rem 引数(3)　実行コマンド（スペースを含む場合は二重引用符で囲むこと）
rem ----------------------------------------------------------------------------
:AutoBuild
setlocal
rem ----------------------------------------------------------------------------
rem ターゲットファイルが存在しなければビルドを実行する
rem ----------------------------------------------------------------------------
if not exist %1 goto AutoBuild_Exec
rem ----------------------------------------------------------------------------
rem 一次チェック
rem
rem ・全ソースファイルのうちターゲットファイルよりも新しいファイルが一つでも存在
rem 　すればビルドを実行する
rem ・全ソースファイルのうちターゲットファイルと同時分のファイルが存在すれば
rem 　新旧不明として二次チェックに進む
rem ・ソースファイル数をカウントする　※二次チェックで使用する
rem ----------------------------------------------------------------------------
set Count=0
set ExecBuild=0
set Ambiguous=0
for %%I in ( %~2 ) do (
  if "%~t1" LSS "%%~tI" set ExecBuild=1
  if "%~t1" EQU "%%~tI" set Ambiguous=1
  set /a Count+=1
)
if %ExecBuild%==1 goto AutoBuild_Exec
if %Ambiguous%==0 goto AutoBuild_Return
rem ----------------------------------------------------------------------------
rem 二次チェック
rem
rem 全ソースファイルとターゲットファイルを更新日時でソートして一番新しいファイル
rem がターゲットファイルの場合はビルドを実行しない
rem ----------------------------------------------------------------------------
set ExecBuild=1
for /F "skip=%Count%" %%I in ( 'dir /B /OD /TW %~1 %~2' ) do (
  if /I %%I==%~1 set ExecBuild=0
)
if %ExecBuild%==0 goto AutoBuild_Return
rem ----------------------------------------------------------------------------
rem ビルド実行
rem ----------------------------------------------------------------------------
:AutoBuild_Exec
echo %~3
     %~3
:AutoBuild_Return
endlocal
exit /b
```

# 備考

自分で言うのもなんだが，今回作ったバッチファイルは二度と使わないかもしれない。

# 参考文献

- [コマンドプロンプトでファイルの更新日時をミリ秒単位まで取得する - Qiita](https://qiita.com/Targityen/items/fb29ca4940641158fa7d)

- [バッチで作成日・更新日を取り出す方法 - Hatena Blog](https://hitorininaritai.hatenablog.com/entry/2021/09/14/214321)

- [コマンドプロンプトを使ってファイルの更新日付を秒まで取得する - mebee](https://mebee.info/2021/07/19/post-31169/)