バッチファイルにPowerShellスクリプトを埋め込む 標準入力対応版

PowerShellスクリプトをダブルクリックやドラッグ&ドロップで実行できるようにできるだろうか?と情報を探していたところ、下記のサイトを見つけた。

pf-j.sakura.ne.jp

pf-j.sakura.ne.jp

これを読んでみてもうちょっと改良できそうかも?と思い試行錯誤していろんなものが得られたので記事を起こすことにした。

この記事に含まれるスクリプトは特に著作権表示無く使用してもらって構わない。

改変点

  • 標準入力$inputを扱えるようにした(重要)
  • スクリプトのパスを$PSCommandPath$PSScriptRootから取得できるようにした
  • 最初の1行以外はすべてPowerShellスクリプトを書けるようにした
  • 変態さを増した(変態)

スクリプトの例

フルスペック版

@setlocal enabledelayedexpansion&set a=%*&(if defined a set a=!a:"=\"!&set a=!a:'=''!)&powershell/c $i=$input;iex ('$i^|^&{$PSCommandPath=\"%~f0\";$PSScriptRoot=\"%~dp0";#'+(${%~f0}^|Out-String)+'} '+('!a!'-replace'[$(),;@`{}]','`$0'))&exit/b
# ここからPowerShellスクリプト

"Executing $PSCommandPath..."
"$($args.length) parameter(s) passed"
$args | %{ $i = 0 }{ echo "`$args[$i]: $_"; $i++ }
"`$input: $input"

最初の1行をコピペして拡張子をバッチファイル("cmd"または"bat")にすれば2行目以降に好きなPowerShellスクリプトを書くことができる。

簡易版

制限: PowerShell バージョン3.0以上限定、コマンドライン引数が使用できない、標準入力が使用できない、スクリプトのパスが取得できない

@powershell/c '#'+(gc \"%~f0\"-ra)^|iex&exit/b
# ここからPowerShellスクリプト

"Time: $([DateTime]::Now)"
"Guid: $([guid]::NewGuid())"

たぶんこれが一番短いと思います。

解説 (TL;DR)

設計思想

おまじない的なものをバッチファイルの先頭にぺたっと貼り付けておけば特に何も考えなくてもスクリプトを書ける環境を目指した。

あと、おまじないは目立ってはいけないので保守性よりも文字数削減を優先した。

バッチファイルとしての動作

このスクリプトは1行目だけがバッチファイルとして動作するようになっている。

行頭の@(アットマーク)

スクリプトの実行時にその行だけコマンドをエコーさせない効果を持つ。

@echo offと書けばそれ以降は各行頭に@を書く必要は無くなるのだがecho off↵は11 byteである。 スクリプトが10行に満たない場合は各行頭に@を書く方法の方が文字数を削減できる。

setlocal enabledelayedexpansion&set a=%*

スクリプトに渡されたコマンドライン引数をPowerShellに渡す前の下処理のため環境変数を使用している。 setlocalによって環境変数のスコープをスクリプト単位に限定し、set a=%*によってコマンドライン引数をまるごとaという環境変数に格納する。 また、enabledelayedexpansionは後述する環境変数の遅延展開のため指定している。

&(アンパサンド)

&と改行はどちらもコマンドを区切る効果を持つ。 &は1 byte、改行は@含めて3 byteであるため、どうしても場合以外は&を使う方が文字数を削減する。

(if defined a set a=!a:"=\"!&set a=!a:'=''!)

スクリプトコマンドライン引数が渡されていない場合、set a=%*の結果、環境変数aは未定義状態となる。 環境変数に格納された文字列は%somevar:target=replace%のような文法で置換することができるが、環境変数somevarが未定義の場合結果は空文字列ではなく変な文字列になってしまう。それを防ぐため、環境変数aが定義されているかどうかチェックするためifコマンドを入れた。 なお、ifの文法はちょっと特殊で、後続する&は条件にマッチするときのみ実行されるようになっているのでコマンド全体を()で囲っている。

環境変数の遅延展開のおかげで、本来は改行を挟まなければset a=%*が呼び出されるよりも前の状態で%a%が展開されてしまうが、代わりに!a!を使うことでコマンドの実行時に環境変数を展開することができる。

setコマンドを2回実行しているがこれは"(ダブルクォーテーションマーク)、'(シングルクォーテーションマーク)の2文字をエスケープしている。 前者はバッチファイル側の都合で、コマンドの引数にダブルクォーテーションマークを含む文字列を渡そうとするとバックスラッシュによるエスケープが必要になるためここで変換しておく。 後者はPowerShellスクリプト側の都合で、'を用いた文字列内で'を使用するために''へ変換している。

powershell/c

準備が整ったのでようやくpowershell.exeを実行する。 -Commandオプションによりあとに続く文字列をPowerShellスクリプトを実行することができる。 引数は前方一致で選ばれるためわざわざ-Commandと8 byteも書かなくても-cの2 byteだけで通じる。 また、引数の前の-(ハイフン)は実は/(スラッシュ)でも代用可能であり、空白文字1 byteを節約できるので/を選んだ。

exit/b

バッチファイルを終了するのに/bが必要となるが、ここでもしっかりと/前の空白文字を節約する。 オプション引数でエラーコードを返すことができるが、exitコマンド自体はエラーコードを上書きしないのでexit/b !errorlevel!と書かなくてもちゃんと環境変数errorlevelは保存されている。

exit/bされたあとはバッチファイルとしてはパースされないので何を書いてても問題ない。

なお、goto:eofでもexit/bと同じ効果が得られるが文字数節約のためexitとしている。

PowerShellスクリプトとしての動作

$i=$input;

次に示すInvoke-Expressionでは標準入力$inputを与える方法が無いにも関わらず空文字列で上書きされてしまうので、上書きされる前に$inputの中身を別の変数に退避させた。

iex ('$i^|^&{...'+'} '+('!a!'-replace'[$(),;@`{}]','`$0')

Invoke-Expressionコマンドを使って文字列をPowerShellスクリプトとして実行している。 iexInvoke-Expressionに既定で設定されているエイリアスである。

文字列が示すスクリプトでは、スクリプトブロック{...}を定義して即座にそれを&で実行している。 スクリプトブロック実行時に先ほど退避させた$inputをリダイレクト、引数はバッチファイルの環境変数%a%が展開されたものを一部正規正規置換を施した上で渡している。 スクリプトコマンドライン引数はPowerShellの文法的に意味のある記号の前に-replace演算子`を追加することでがとしてエスケープしている。

$PSCommandPath=\"%~f0\";$PSScriptRoot=\"%~dp0"

PowerShellでは既定のグローバル変数として$PSCommandPathスクリプトファイルへのパス(通常は拡張子".ps1")と、$PSScriptRootにその格納フォルダへのパスが格納されている。 既定のグローバル変数とバッティングするため、スクリプトブロック内のローカル変数として$PSCommandPath$PSScriptRootを定義している。 パス自体はバッチファイル実行時に定義される変数%0をファイルパス形式%~f0と親フォルダパス形式%~dp0から受け取っている。

なお、最後の"だけ\でエスケープしていないのは誤記ではない。%~dp0は最後に\が付く一方で$PSScriptRootには\が付かない。%~dp0の最後に必ず付く\"のエスケープに使用して相殺している。

('...#'+(${%~f0}^|Out-String)+'...')

丸括弧内では${%~f0}という名前の変数から値を読みだしてOut-Stringコマンドレットに渡している。 PowerShellとして実行される際には%~f0はドライブレターから始まる文字に展開されており、バッチファイル自身を読み出すことができる。 ただし、読みだされた結果はGet-Contentコマンドと同様に"1つの文字列"ではなく"文字列の配列"となっているため、 Out-Stringコマンドレットで改行を含む1つの文字列に変換している。

${%~f0}と記述するとgc \"%~f0\"と記述するよりも2文字削減することができる。 ただし、ファイル名本体に丸括弧"{","}"が使えなくなるので注意。

ファイルを読み込む文字列の直前に#を入れ込むことにより、取得したファイル、つまりこのバッチファイル自体の1行目を無視することができる。

なお、(${%~f0}^|Out-String)の部分は次に説明するものに置き換えるとさらに5文字短くなる。

('...#'+(gc \"%~f0\"-ra)+'...') (PowerShell 3.0以上限定)

丸括弧内ではGet-Contentコマンドレットを使用してバッチファイル自身をテキストファイルとして読み込んでいる。 gcGet-Contentに既定で設定されているエイリアスである。 Get-Contentコマンドは標準ではイテレータ?を返すようになっているが-Rawオプションを指定すると文字列を返すようになる。 例によって前方一致で1文字節約している。

旧版解説

2つ目の記事に気付かずに書いたものがあったので差分だけ残しておく。

@('*)2>nul&setlocal enabledelayedexpansion&set a=%*&if defined a set a=!a:`=````!&set a=!a:"=`\"!&set a=!a:$=```$!
@powershell/c $$=$input;iex \"`$$|&{`$PSCommandPath=`\"%~f0`\";`$PSScriptRoot=`\"%~dp0`\";$(gc `\"%~f0`\"-ra)} %a%\"&exit/b
')>$()

('*)2>nul

PowerShellスクリプトの都合上、@('を必ず最初に書かなければならないがバッチファイルにとってはなにもしない記述でなければらない。 バッチファイルにとっても(は複数のコマンドを1つにまとめる効果を持つので必ず)で閉じる必要がある。 2>nulによって標準エラー出力に吐かれるエラーメッセージを闇に葬り去ることができる。

また、Windowsファイルシステムでは'(シングルクォーテーションマーク)はファイル名に使うことができる文字なので@(')2>nulと書いてしまうと万が一'.exeなどの実行ファイルにパスが通っているとそれが実行されてしまうことになる。 副作用をふせぐためファイル名に使用できない*(アスタリスク)を挿入した。

改行

ifコマンドを脱出するため&ではなく改行を使用した。

文字数が同じなので見栄え重視で改行を選んだ。

@('...')>$()

ここからスクリプトファイルをファイルの先頭からPowerShellスクリプトとして読み直す。

@()は配列を定義する文法であり、文字列1つを内包している。 'で挟まれた中身が文字列となるが、改行を含むことができるようになっているためバッチファイルとしての文字列はほとんど無視される。

また、配列を1つ書いただけではプロンプトにその配列自身が表示されることになってしまう。 PowerShellでは$nullにオブジェクトをリダイレクトすることによって配列を闇に葬り去ることができる。 $()は括弧の中に何も書かないと$nullとして評価される。文字数節約のため$()と記載した。

ここからあとはおよそすきなPowerShellスクリプトを書くことができる。

その他

リファレンスなど読まずに解説部分を書いたので色々間違いはあるかもしれない。