PowerShellの引数展開に関する仕様まとめ

9日の枠が空いていたので、穴埋めで投稿しました。

PowerShellコンソールからCmdletや外部コマンドに対して渡す引数が
どういう法則で展開されているのかをまとめてみました。

`Do-Something 引数1` という文を実行した場合、引数1は展開されてコマンド側に渡るのか
そのまま文字列として渡るのか、というお話です。

# 引数のParsing仕様
引数の展開規則に関しては以下のaboutのページが用意されています。[^1]
[^1]: PowerShell Coreもこの仕様から変更されてないようなので、日本語のドキュメントがあるWindowsPowerShellの方を載せておきます。
[about_Parsing](https://docs.microsoft.com/ja-jp/previous-versions/windows/powershell-scripting/hh847892(v=wps.640))

仕様は以下の部分に集約されています。

> Windows PowerShell のパーサーは、コマンドを処理するときに式モードまたは引数モードで動作します。
 
> - 式モードでは、文字列値が引用符で囲まれている必要があります。引用符で囲まれていない数値は、(一連の文字ではなく) 数値として扱われます。
 
> - 引数モードでは、次のいずれかの特殊文字で始まる場合を除き、個々の値が展開可能な文字列として扱われます。ドル記号 ($)、アット マーク (@)、一重引用符 (')、二重引用符 (")、または始めかっこ (()。
 
> これらの文字のいずれかで始まる値は、値の式として扱われます。

つまり、この特殊な5文字（`$@'"(`）以外で始まる文字列（トークン）は全てString型としてコマンド側に渡り、
前述の特殊文字から始まる場合は、引数の値に応じてStringやInt32、Booleanなどに評価されてから渡るようです。

## 引数の型ごとに挙動確認
本当にそうなのか、何パターンか試して確認してみます。

検証に使用する関数を定義。

```powershell
function checkArgs($param) {
  return "$param, $($param.GetType().Name)"
}
```

### 文字列型

数値や特殊文字（`$@'"(`）以外の引数は全て文字列型となります。

```powershell
# 文字列値が引用符で囲まれているので、式モードとなります。
checkArgs "hoge"
#=> hoge, String

# シングルクォートも一緒です。
checkArgs 'hoge'
#=> hoge, String

# 単に引用符を外しても同じ結果になります。
checkArgs hoge
#=> hoge, String

# 変数を文字列内に埋め込んでいる場合でも引用符は必須ではありません。
$today = [datetime]::Today.ToString('yyyy/MM/dd')
checkArgs 今日は${today}です
#=> 今日は2018/12/21です, String
# 個々の値が展開可能な文字列として扱われる、と書かれているので、
# 関数側に`今日は${today}です`という文字列が渡ってから展開されているっぽいですが、詳細は不明です。
```

### 数値型

特殊文字（`$@'"(`）で始まらない引数が数値のみで構成されている場合、数値型となります。

```powershell
# 引用符で囲まれていない数値なので、Int32型になります。
checkArgs 23
#=> 23, Int32

# 文字列値が数値以外も含まれるため、Stringとして評価されます。
checkArgs 23+42
#=> 23+42, String

# この場合、括弧で囲んでやれば、適切に評価されるようになります。
checkArgs (23+42)
65, Int32

# なお、下記の記法だと、1番目の引数としては23しか評価されません。
checkArgs 23 + 42
#=> 23, Int32
```

### ブール型

```powershell
# `$`から始めるため、展開された後の値が関数に渡されます。
checkArgs $true
#=> True, Boolean

# `!`は特殊文字には入っていないため、展開されずにString型として関数に渡されます。
checkArgs !$true
#=> !True, String

# こちらも、括弧で囲んでやれば、適切に評価されるようになります。
checkArgs (!$true)
False, Boolean
```

### プリミティブ型以外

```powershell
# `[`は特殊文字には入っていないため、展開されずにString型として関数に渡されます。
checkArgs [datetime]::Today
#=> [datetime]::Today, String

# こちらも、括弧で囲んでやれば、適切に評価されるようになります。
checkArgs ([datetime]::Today)
#=> 12/18/2018 00:00:00, DateTime
```

### 複合型（配列、Hashtable）

```powershell
# `@`から始まるため、配列型に展開された値が関数に渡されます。
# カンマの後にスペースが入ってたとしても、`@(23,`だけが第1引数に渡されるようなことはありません。
checkArgs @(23, 42)
#=> 23 42, Object[]

# Hashtableも`@`から始まるため、適切に評価されます。
checkArgs @{hogw = 23; fuga = 42}
System.Collections.Hashtable, Hashtable

# スプラッティングについても各値の型によって、適切に評価されるようです。
$params = @{param = "splatting"}
checkArgs @params
#=> param = "splatting", String
```

## その他（罠っぽい挙動をするケース）

上記のサンプルコード内でも、既に罠っぽい挙動はいくつかありましたが、
その他にも[about_Parsing](https://docs.microsoft.com/ja-jp/previous-versions/windows/powershell-scripting/hh847892(v=wps.640))のルールに矛盾しているような挙動が既に何パターンか指摘されています。
[Parsing of compound command-line tokens into arguments is surprising #6467](https://github.com/PowerShell/PowerShell/issues/6467)

以降、そちらの挙動の確認結果です。

### 引用符か部分式が最初に来た場合とそれ以外で挙動が変わる

引用符（`''`, `""`）や括弧（`()`）で囲まれた文字列が引数値の先頭にあった場合、
その部分だけで引数の境界として扱われます。

```powershell
# 第1引数に'hi'、第2引数にthereとして評価される。
> Write-Output 'hi'there
#=> hi
#=> there

# 第1引数に`$env:HOME`、第2引数に`/Documents`として評価されます。
> Write-Output $($env:HOME)/Documents
#=> /Users/jdoe
#=> /Documents
```

一方、引数値の始まりが引用符（`''`, `""`）や括弧（`()`）でなければ、
スペースがない限り、引数1個として評価されます。

```powershell
> Write-Output hi'there'
#=> hithere

> Write-Output Documents:$($env:HOME)
#=> Documents:/Users/jdoe
```

### 引用符で囲まれた文字列に続いて記述された`.`はプロパティアクセス演算子として解釈される

これは、直感でもそうなるとだいたい予想でできる挙動なので、そこまで問題にはならないと思いますが、
同じISSEUSで記載されていたので、ついでに紹介します。

```powershell
# String型にthereというプロパティはないので、何も表示されません。
> Write-Output 'hi'.there
#=>

# 引用符で囲まれていなければ、そのまま表示されます。
> Write-Output hi.there
#=> hi.there
```

### 先頭文字が`.`で続く値が引用符で囲まれた文字列だった場合2つの引数として解釈される

素直に`'.hi'`と書けば問題ないですが、`.$relativePath`みたいな書き方をしたい場合はハマりそうです。

```powershell
# 第1引数に.、第2引数に`hi`として評価されます。
> Write-Output .'hi'
#=> .
#=> hi
```

### 先頭文字が`-`で続く文字列内に`:`または、`.`があった場合、名前付き引数として解釈される

これも、場合によっては引っ掛かりそうな挙動になるかもしれません。
`-`始まりの文字列を渡したい場合はいずれのケースも \`- のように`-`をエスケープすれば問題ありません。

```powershell
# 第1引数に`-foo:`、第2引数に`bar`として評価されます。
> Write-Output -foo:bar
#=> -foo:
#=> bar

# 第1引数に`-ip=10`、第2引数に`.0.0.1`として評価されます。
> Write-Output -ip=10.0.0.1
-ip=10
.0.0.1
```

## 解析停止記号 (--%) 
特殊な5文字（`$@'"(`）を引数として渡す値に使用したい場合、
解析停止記号 (--%) でエスケープすることができます。
>Windows PowerShell 3.0 で導入された解析停止記号 (--%) は、入力を Windows PowerShell のコマンドや式として解釈しないように Windows PowerShell に指示します。

```powershell
# まずは、解析停止記号なしのパターン
cmd /c echo ([datetime]::Today.ToString('yyyy-MM-dd'))
#=> 2018-12-22

# 解析停止記号を付けて実行すると、`()`内でもそのまま文字列としてコマンドに渡されます。
cmd /c --% echo ([datetime]::Today.ToString('yyyy-MM-dd'))
#=> ([datetime]::Today.ToString('yyyy-MM-dd'))
```

この`--%`ですが、どうやらパースの挙動がコマンドプロンプトと同じになるもので、
単純にPowerShellのパースを無効化にするというよりも、パーサーをpowershell.exeからcmd.exeに切り替える、
というのが、正しいようです。

よって、環境変数の参照方法`%USERNAME%`であったり、エスケープの仕方などもコマンドプロンプトに合わせる必要があります。
解析停止記号、というより、コマンドプロンプトの解析モード切替記号といった方が正しいかもしれません。

なお、PowerShellのコマンドレットや関数、ps1スクリプトでは`--%`が引数として解釈されるため、
期待通りの動作はしないとのことです。
よって、PowerShellで解析を抑制させたい場合は、以下のようにエスケープ文字を入れるか
シングルクォートで囲むかすることで回避する必要があります。

```powershell
# `()`という空ファイルを作成。
New-Item -ItemType File -Name '()'
#=>     ディレクトリ: C:\Users\xxxxx\path\to
#=> Mode                LastWriteTime         Length Name
#=> ----                -------------         ------ ----
#=> -a----       2018/12/22     17:20              0 ()

Get-ChildItem ()
#=> エラー
#=> 式が '(' の後に必要です。

Get-ChildItem --% ()
#=> エラー
#=> パス 'C:\Users\xxxxx\path\to\--%' が存在しないため検出できません。

Get-ChildItem `(`)
#=>     ディレクトリ: C:\Users\xxxxx\path\to
#=> Mode                LastWriteTime         Length Name
#=> ----                -------------         ------ ----
#=> -a----       2018/12/22     17:20              0 ()

# または、こちらでも可能。
Get-ChildItem '()'
```


# 感想
迷ったら引用符（`""`）か括弧（`()`）で囲って渡してやるのが無難。

# 参考文献
[about_Parsing](https://docs.microsoft.com/ja-jp/previous-versions/windows/powershell-scripting/hh847892(v=wps.640))
[Parsing of compound command-line tokens into arguments is surprising #6467](https://github.com/PowerShell/PowerShell/issues/6467)
[powershell.exe -Commandパラメーターの謎挙動について](https://blog.shibata.tech/entry/2016/03/29/193153)