ファイル／フォルダ名の大文字・小文字を変換するバッチファイルをささっと作る

初出の記事ではファイル名に括弧 `()` 含まれる場合にエラーになっていたため，これを修正しました（2024年8月31日）

# 0. はじめに

Windows 上でクロス開発を行うときに起こる問題の一つに，ヘッダファイルの大文字・小文字問題がある。Windows のファイルシステムはファイル名の大文字・小文字を区別しないので，コーディング中にも気にしない人が多いが，いざコンパイルしようとするとヘッダファイルが見つからないというエラーに驚く。

本来，コンパイラに大文字・小文字を区別しないオプションがあれば良いのだが，残念ながらそのような便利なオプションは見当たらない。我々の開発チームはソースコードを書き換えたくないということでファイル名のほうを変更することになった。
※とても最善の策とは思えないが，チームの方針には従うしかない。

# 1. お品書き（仕様案）

- 簡単に作れるバッチファイルでミスなく機械的に処理したい。
  大文字に変えるほうは to Upper Case ということで `UCASE.CMD`，小文字に変えるほうは to Lower Case ということで `LCASE.CMD` とする。
- 複数のファイル名を指定できる。ファイル名にはワイルドカードを使用できる。
- オプション `/V` を指定すると変更対象となるファイル名を表示するだけで変更しない。
  ※これは事前確認のため
- オプション `/Q` を指定すると変更したファイル名を表示しない。デフォルトは表示する。
- オプション `/D` を指定するとディレクトリ名（のみ）を変更する。デフォルトはファイル名（のみ）を変更する。

# 2. ファイル名（フォルダ名）の変更方法

`SET` コマンドの文字列置換機能を使う。実は `SET` コマンドでは文字列検索の際に大文字・小文字を区別しないので小文字を大文字に変換する場合は `set STR=%STR:a=A%` とする必要はなく `set STR=%STR:A=A%` でも良い。このため

```:大文字に変換するとき（その１）
set STR=%STR:a=A%
set STR=%STR:b=B%
set STR=%STR:c=C%
set STR=%STR:d=D%

～中略～

set STR=%STR:z=Z%
```

のようにずらずらとアルファベット26文字分の変換処理を並べる必要はなく

```:大文字に変換するとき（その２）
for %%C in ( A B C D E F G H I J K L M N O P Q R S T U V W X Y Z ) do (
  set "STR=!STR:%%C=%%C!"
)
```

のようにループ処理で書ける。ここで注意すべきことは

- ループ処理になるので遅延環境変数にする（変数名をパーセント記号 `%` ではなく感嘆符 `!` で囲む）必要があること。また `setlocal enabledelayedexpansion` の指定も必要。
- プログラムのファイル名に括弧記号 `()` を使うような人は滅多にいないと思うが，もしも使っていると `for` コマンドの括弧記号と混同してエラーになるので，二重引用符 `"..."` で囲む必要がある。二重引用符で囲む範囲にも注意されたい。

ちなみに小文字に変換するときは下記のようになる。

```:小文字に変換するとき
for %%C in ( a b c d e f g h i j k l m n o p q r s t u v w x y z ) do (
  set "STR=!STR:%%C=%%C!"
)
```

# 3. FOR コマンドの闇

複数のファイルをループ処理したい場合は `for` コマンドを使うと便利である。コマンドラインで指定したファイル名（複数）を `%*` とする。

```:ファイルに対するループ処理
for %%I in ( %* ) do ...
```

一方，フォルダを対象とする場合は `/D` オプションを付ける。

```:フォルダに対するループ処理
for /D %%I in ( %* ) do ...
```

ここで引数 `%*` を展開した文字列がワイルドカードを含む場合，実在するファイル（またはフォルダ）のみを列挙してくれるのだが，ワイルドカードを含まない場合，そのようなファイル（またはフォルダ）が実在しなくても引数の文字列をそのまま列挙してしまうので注意が必要だ。

# 4. ファイルとフォルダの区別

ファイル（またはフォルダ）の実在チェック自体は `if exist %%I ...` で可能だが，ファイルとフォルダの区別がつかない。このためファイルの属性 `%%~aI` を得て判断する。ファイルの場合は下記のようになる。

```:ファイルの場合の属性文字列
--a--------
```

一方，フォルダの場合は下記のようになる。

```:フォルダの場合の属性文字列
d----------
```

すなわち文字列の先頭が `-` の場合はファイルであり，`d` の場合はフォルダとなる。ちなみにファイル（またはフォルダ）が存在しない場合は空文字列になる。Windows10 22H2 の場合，属性文字列の長さは 11 文字であり，属性の割り当ては下記のようになっている。
※適当な資料が見当たらないため筆者の調査による。

```:属性文字列
drahscotl-x

d ... ディレクトリ
r ... 読み取り専用
a ... アーカイブ
h ... 隠しファイル
s ... システムファイル
c ... 圧縮
o ... オフライン
t ... テンポラリ
l ... 再解析ポイント
x ... スクラブファイルなし
```

ちなみに `l` と `x` の間にある属性は謎である。暗号化 `e` やスパースファイル `p` でもないし，インデクス化 `i` でも無さそうだ。

# 5. 実装コード

実装コードを以下に示す。

```:UCASE.CMD（ファイル名のアルファベット小文字を大文字に変換するバッチファイル）
@echo off
rem ------------------------------------------------------------------------
rem ファイル名（複数可）を大文字に変換する
rem ------------------------------------------------------------------------ 
setlocal enabledelayedexpansion
set DOPT=0
set QOPT=0
set VOPT=0
rem ------------------------------------------------------------------------
rem オプション解析
rem ------------------------------------------------------------------------
set ARGS=
if "%~1"=="" goto USAGE
:LOOP
  set OPT=%~1
  if /I "%OPT%"=="/D" set DOPT=1&&goto ENDIF
  if /I "%OPT%"=="/Q" set QOPT=1&&goto ENDIF
  if /I "%OPT%"=="/V" set VOPT=1&&goto ENDIF
  if "%OPT:~0,1%"=="/" goto ERROR
  if defined ARGS (
    set ARGS=%ARGS% "%~1"
  ) else (
    set ARGS="%~1"
  )
  :ENDIF
  shift
  if "%~1"=="" goto BREAK
goto LOOP
:ERROR
echo オプション %OPT% には対応していません！
exit /b
:BREAK
if not defined ARGS (
  echo ファイル名を指定して下さい！
  exit /b
)
rem ------------------------------------------------------------------------
rem ファイル名／フォルダ名を変更する
rem ------------------------------------------------------------------------
if "%DOPT%"=="1" (
  for /D %%I in ( %ARGS% ) do (
    set ATTR=%%~aI
    if "!ATTR:~0,1!"=="d" call :CONVERT "%%~I"
  )
) else (
  for %%I in ( %ARGS% ) do (
    set ATTR=%%~aI
    if "!ATTR:~0,1!"=="-" call :CONVERT "%%~I"
  )
)
exit /b
rem ------------------------------------------------------------------------
rem ファイル名／フォルダ名を変更する
rem ------------------------------------------------------------------------
:CONVERT
set OLDNAME=%~nx1
set NEWNAME=%OLDNAME%
for %%C in ( A B C D E F G H I J K L M N O P Q R S T U V W X Y Z ) do (
  set "NEWNAME=!NEWNAME:%%C=%%C!"
)
if "%OLDNAME%"=="%NEWNAME%" exit /b
if "%QOPT%"=="0" echo %~1	%NEWNAME%
if "%VOPT%"=="0" ren "%~1" "%NEWNAME%"
exit /b
rem ------------------------------------------------------------------------
rem ヘルプメッセージ
rem ------------------------------------------------------------------------
:USAGE
echo ファイル名（複数可）を大文字に変更します。
echo.
echo UCASE(.CMD) [/D] [/Q] [/V] [ドライブ:][パス][ファイル名]
echo.
echo /D  フォルダ名を変更します。
echo /Q  変更したファイル名を表示しません。
echo /V  変更対象となるファイル名を表示するだけで、変更は行いません。
exit /b
```

小文字に変換するほうも同様である。

```:LCASE.CMD（ファイル名のアルファベット大文字を小文字に変換するバッチファイル）
@echo off
rem ------------------------------------------------------------------------
rem ファイル名（複数可）を小文字に変換する
rem ------------------------------------------------------------------------ 
setlocal enabledelayedexpansion
set DOPT=0
set QOPT=0
set VOPT=0
set ARGS=
rem ------------------------------------------------------------------------
rem オプション解析
rem ------------------------------------------------------------------------
if "%~1"=="" goto USAGE
:LOOP
  set OPT=%~1
  if /I "%OPT%"=="/D" set DOPT=1&&goto ENDIF
  if /I "%OPT%"=="/Q" set QOPT=1&&goto ENDIF
  if /I "%OPT%"=="/V" set VOPT=1&&goto ENDIF
  if "%OPT:~0,1%"=="/" goto ERROR
  if defined ARGS (
    set ARGS=%ARGS% "%~1"
  ) else (
    set ARGS="%~1"
  )
  :ENDIF
  shift
  if "%~1"=="" goto BREAK
goto LOOP
:ERROR
echo オプション %OPT% には対応していません！
exit /b
:BREAK
if not defined ARGS (
  echo ファイル名を指定して下さい！
  exit /b
)
rem ------------------------------------------------------------------------
rem ファイル名／フォルダ名を変更する
rem ------------------------------------------------------------------------
if "%DOPT%"=="1" (
  for /D %%I in ( %ARGS% ) do (
    set ATTR=%%~aI
    if "!ATTR:~0,1!"=="d" call :CONVERT "%%~I"
  )
) else (
  for %%I in ( %ARGS% ) do (
    set ATTR=%%~aI
    if "!ATTR:~0,1!"=="-" call :CONVERT "%%~I"
  )
)
exit /b
rem ------------------------------------------------------------------------
rem ファイル名／フォルダ名を変更する
rem ------------------------------------------------------------------------
:CONVERT
set OLDNAME=%~nx1
set NEWNAME=%OLDNAME%
for %%C in ( a b c d e f g h i j k l m n o p q r s t u v w x y z ) do (
  set "NEWNAME=!NEWNAME:%%C=%%C!"
)
if "%OLDNAME%"=="%NEWNAME%" exit /b
if "%QOPT%"=="0" echo %~1	%NEWNAME%
if "%VOPT%"=="0" ren "%~1" "%NEWNAME%"
exit /b
rem ------------------------------------------------------------------------
rem ヘルプメッセージ
rem ------------------------------------------------------------------------
:USAGE
echo ファイル名（複数可）を小文字に変更します。
echo.
echo LCASE(.CMD) [/D] [/Q] [/V] [ドライブ:][パス][ファイル名]
echo.
echo /D  フォルダ名を変更します。
echo /Q  変更したファイル名を表示しません。
echo /V  変更対象となるファイル名を表示するだけで変更しません。
exit /b
```

# 6. 実行例

引数なしで実行するとヘルプメッセージを表示する。

```:引数なしで実行した場合
C:\>ucase
ファイル名（複数可）を大文字に変更します。

UCASE(.CMD) [/D] [/Q] [/V] [ドライブ:][パス][ファイル名]

/D  フォルダ名を変更します。
/Q  変更したファイル名を表示しません。
/V  変更対象となるファイル名を表示するだけで変更しません。
```

ファイル名の指定にはワイルドカードを使用できる。変更前後のファイル名を表示する。区切り記号はタブである。

```:ワイルドカードを用いた場合
C:\Qiita>ucase *.h
sample.h        SAMPLE.H
```

また，カレントフォルダのファイルに限らず，以下のようにサブディレクトリ以下のファイルも指定できる。

```:カレントフォルダ直下のファイル以外も指定できる
C:\Qiita>ucase include\*.h
include\sample.h        SAMPLE.H
```

# 7. 今後の課題

フォルダの階層が深いと，サブディレクトリ以下を再帰的に検索する機能が欲しくなる。ただ，これをバッチファイルで実現しようとするといくつかの技術的課題をクリアしなくてはならない。

# 8. 参考文献

そもそものクロスコンパイルの問題は参考文献[1][2]の技術を使えば解決できそうであるが，敷居が高そうだ。ファイル名の大文字・小文字変換のテクニックは参考文献[5]による。文献[6]によれば`for` コマンドのファイル属性文字列は Windows 2000 の頃は長さ 6 文字までしか無かったようだ。OS のバージョンアップに伴い，ファイル属性文字列は拡張されて長くなっているが，文字列の順序（上位互換性）は保たれているようである。

0. [clang cross compile では case-sensitive file system では大文字小文字を区別しないヘッダを読み込めない - Qiita](https://qiita.com/syoyo/items/1833e2c4592a1a28431c)
0. [clang-cl(clang) で case-insensitive でヘッダファイルを読み込む - Qiita](https://qiita.com/syoyo/items/e3e67794b4b482adb01d)
0. [汝、コマンドプロンプトを愛せよ - Qiita](https://qiita.com/mima_ita/items/90f709cad32ca4e7413d)
0. [ファイル名が強制的に大文字になるときの対処 - Qiita](https://qiita.com/jtFuruhata/items/8d843009e5c4dad814c7)
0. [コマンドプロンプト（cmd.exe）私的メモ - Qiita](https://qiita.com/q1701/items/d81e51a64f41ca841da6)
0. [Windows 2000活用講座 Windows 2000 コマンドライン徹底活用 第7回 forコマンド - itmedia](https://atmarkit.itmedia.co.jp/ait/articles/0104/27/news002_2.html)
0. [Windowsのエクスプローラーで表示される属性情報文字の意味は？ - itmedia](https://atmarkit.itmedia.co.jp/ait/articles/1409/26/news133.html)