[ネタ]PowerShellでテンプレートエンジン作ってみた[車輪の再発明]

# 動機

~~需要はお察しですが、せっかく作ったので。~~

C#にプリプロセッサみたいなものが欲しいなぁと思いました。
ただ普通そういうときに使うT4もそうなんですが、適当なテンプレートエンジンを使うとどうしてもC#の構文に違反してしまいます。
違反した状態ではその部分がエラーと表示され、コード補完にも影響してIDEのサポートは満足に受けられなくなってしまいます。
現代でIDEのサポートを放棄する選択肢は基本的にありえないのでどうにかしたいと思いました。
ついでにPowerShellのブロックコメントでタグっぽい表現とか構文エラーが関係ないならPowerShellの変数展開構文をそのまま使えばC#コードに限らす他の用途にも使えそう。

# 仕組み

テンプレートに関わる構文はコメント内に記述することでC#構文に違反しないようにしています。
このエンジンの主な役割は正規表現によってコメント構文を取り除きPowerShellの変数展開が正しく反映するようにすることです。

出力をPowerShellの制御構文で制御できるように一度中間のPowerShellコードを吐き出し、それをInvoke-Expressionで実行することでテンプレート適用後のコードを出力する仕組みにしました。

# コード

```powershell:PSTP.bat
<# :
@setlocal
@powershell -c "iex((cat '%~f0' -Encoding UTF8) -join [Environment]::NewLine); Invoke-PSTemplate %*"
@exit /b %ERRORLEVEL%
#>

function Invoke-PSTemplate
{
    [CmdletBinding()]
    Param (
        [Parameter(Mandatory)]
        [SupportsWildcards()]
        [Alias('InputFile')][string]$__InputFile,            # 変換するファイル名
        [Alias('BindScript')][string]$__BindScript = '',     # 変数バインド用のPowershellScript
        [Alias('OutputDir')][string]$__OutputDir = './out',  # 出力先のディレクトリ
        [Alias('Encoding')][string]$__Encoding = '',         # 出力ファイルの文字エンコード 
        [Alias('StdOutput')][switch]$__StdOutput,            # 出力結果を標準出力に出力
        [Alias('Intermediate')][switch]$__Intermediate,      # 中間コードを標準出力に出力
        [Alias('CheckSubDir')][switch]$__CheckSubDir,        # サブフォルダのチェック
        [Alias('WatchMode')][switch]$__WatchMode,            # ウォッチモード
        [Alias('WatchTime')][int32]$__WatchTime = 5          # ウォッチモードの更新確認間隔(秒)
    )

    #正規表現
    $__IncludePattern = '^[ \t]*//#include[ \t]+"(.+)"'
    $__IncludeTagPattern = '^[ \t]*<#include[ \t]+"(.+)"[ \t]*#>[ \t]*$'
    $__ExpandPattern = '/\*(\$.*?)\*/[a-zA-Z0-9_@]*'
    $__ExpandTagPattern = '<#(\$.*?)#>'
    $__PSScriptPattern = '^[ \t]*//\$(.*)'
    $__PSScriptTagPattern = '^[ \t]*<#\$([^\{\(].*)#>[ \t]*$'
    $__PSScriptMultiStartPattern = '^(/\*)?<#\$$'
    $__PSScriptMultiEndPattern = '^\$#>(\*/)?$'
    
    #ファイルを読み込んで配列に変換
    $__IncludeFile = {
        param ($file)
        return @(Get-Content -Encoding UTF8 $file)
    }

    #すでに取り込んだファイルの読み込み確認・防止
    $__IncludeCheck = {
        param ($incfname, $inctable)
        if (!(Split-Path $incfname -IsAbsolute)) {
            $incfname = Convert-Path $incfname
        }
        $isinced = $inctable.Contains($incfname)
        if (!$isinced) {
            $inctable += $incfname
            $incfile = &$__IncludeFile $incfname
            if (!($null -eq $incfile)) { &$__FileScan $incfile $inctable }
        }
    }

    #ファイルを読み込んで中間コードの生成
    $__FileScan = {
        param ($incfile, $inctable)
        $multimode = $false

        foreach ($incline in $incfile) {
            #複数行PSコードモード
            if($multimode) {
                #複数行PSコード終了の確認
                if ($incline -match $__PSScriptMultiEndPattern) {
                    $multimode = $false
                } else {
                    $__OutputScript.Add($incline)
                }
                continue
            }

            #includeの確認
            if ($incline -match $__IncludePattern) {
                $incfname = $Matches.1
                &$__IncludeCheck $incfname $inctable
                continue
            }

            #タグ形式includeの確認
            if ($incline -match $__IncludeTagPattern) {
                $incfname = $Matches.1
                &$__IncludeCheck $incfname $inctable
                continue
            }

            #コメント形式PSコード確認
            if ($incline -match $__PSScriptPattern) {
                #直接実行するPSコードを生成
                $__OutputScript.Add($Matches.1) | Out-Null
                continue
            }

            #タグ形式PSコード確認
            if ($incline -match $__PSScriptTagPattern) {
                #直接実行するPSコードを生成
                $__OutputScript.Add($Matches.1) | Out-Null
                continue
            }

            #複数行PSコード確認
            if ($incline -match $__PSScriptMultiStartPattern) {
                $multimode = $true
                continue
            }

            #変数展開用の変換処理をして文字列を出力するコードを生成
            $incline = $incline -replace $__ExpandPattern, '$1'
            $incline = $incline -replace $__ExpandTagPattern, '$1'
            $__OutputScript.Add('$__OutputData += $ExecutionContext.InvokeCommand.ExpandString(' + "'" + $incline + "') + [Environment]::NewLine") | Out-Null
        }
    }
    
    #変換処理
    $__ConvertScript = {
        param ($__ConvertFileName) #入力ファイル名は必ずフルパス

        #入力ファイルの読み込み
        $__BaseFile = Get-Content -Encoding UTF8 $__ConvertFileName

        #空ファイルならなにもしない
        if ($null -eq $__BaseFile) { return }

        #ファイル出力用に入力ファイルから相対パスを取得
        $__OutputFileName = Resolve-Path $__ConvertFileName -Relative

        #読み込みファイルの相対パスと出力パスのフルパスからファイルの出力ディレクトリを取得
        $__OutputFileDir = Split-Path ($__FullOutputDir + '/' + $__OutputFileName) -Parent

        #出力用中間コード
        $__OutputScript = [System.Collections.ArrayList]::new()

        #バインド用スクリプトが指定されていれば実行するためのコードを中間スクリプトに追加
        if ($__BindScript -ne '') { $__OutputScript.Add('. ' + "'" + $__BindScript + "'") }

        #エンコードの指定があった場合は出力用にエンコード指定コードを設定
        $__ScriptEncoding = ''
        if ($__Encoding -ne '') { $__ScriptEncoding = ', [System.Text.Encoding]::GetEncoding('+ "'" + $__Encoding + "'" + ')' }

        #ファイルの解析開始
        &$__FileScan $__BaseFile @()

        if ($__Intermediate) {
            #中間コードを出力
            Write-Host ($__OutputScript -join [Environment]::NewLine)
        } else {
            #生成した中間コードに結果出力コードを追加してInvoke-Expressionで実行
            if ($__StdOutput) {
                #コンソール出力コード
                $__OutputScript.Add('Write-Host $__OutputData') | Out-Null
            } else {
                #ファイル出力コード(ディレクトリが存在しない場合は作成するコードを含む)
                $__OutputScript.Add('if (!(Test-Path ' + "'" + $__OutputFileDir + "'" + ')) { New-Item ' + "'" + $__OutputFileDir + "'" + ' -ItemType Directory | Out-Null }') | Out-Null
                $__OutputScript.Add('[System.IO.File]::WriteAllLines(' + "'" + $__FullOutputDir + '/' + $__OutputFileName + "'" + ', $__OutputData' + $__ScriptEncoding + ')') | Out-Null
            }
            Invoke-Expression ($__OutputScript -join [Environment]::NewLine)
        }
    }

    #ファイルの更新をチェックして変換処理を開始
    $__UpdateCheck = {
        param ($__CheckHash, $__Convert)

        #サブディレクトリをすべて取得
        $__SubDirs = @()
        if ($__CheckSubDir) {
            $__SubDirs = Get-ChildItem -Recurse * | Where-Object { $_.PSIsContainer } | ForEach-Object { $_.FullName }
        }

        #カレントディレクトリを追加
        if ($__SubDirs -is [string]){
            $__SubDirs = @($__SubDirs, (Convert-Path .))
        } else {
            $__SubDirs += Convert-Path .
        }

        #カレントディレクトリとすべてのサブディレクトリの更新チェック
        foreach ($__SubDir in $__SubDirs) {
            #出力ディレクトリ内かどうかの確認
            if ($__SubDir.IndexOf($__FullOutputDir) -eq 0) { continue }

            #ディレクトリに対象ファイルが存在するかチェック
            if (!(Test-Path ($__SubDir + '/' + $__InputFileName))) { continue }

            #ワイルドカードを展開
            $__Provider = $null
            $__BaseFileNames = $PSCmdlet.SessionState.Path.GetResolvedProviderPathFromPSPath(($__SubDir + '/' + $__InputFileName), [ref]$__Provider)

            foreach ($__BaseFileName in $__BaseFileNames) {
                #最終更新日時の取得
                $__UpdateCheckData = $(Get-ItemProperty $__BaseFileName).LastWriteTime
                $__RelFileName = Resolve-Path $__BaseFileName -Relative

                #記録がない新規ファイルまたは更新日時が記録と異なる場合は更新されたとみなして変換処理
                if (!$__CheckHash.ContainsKey($__RelFileName)) {
                    $__CheckHash[$__RelFileName] = $__UpdateCheckData 
                    if ($__Convert) { &$__ConvertScript $__BaseFileName }
                } elseif ($__UpdateCheckHash[$__RelFileName] -ne $__UpdateCheckData) {
                    $__CheckHash[$__RelFileName] = $__UpdateCheckData
                    if ($__Convert) { &$__ConvertScript $__BaseFileName }
                }
            }
        }
    }

    #指定ファイルのパスをカレントディレクトリにする
    if (Split-Path $__InputFile -IsAbsolute) {
        Set-Location -Path (Split-Path -Path $__InputFile)
        $__InputFile = ('./' + (Split-Path -Leaf $__InputFile))
    } else {
        if ($__InputFile.IndexOf('./') -ne 0) { $__InputFile = ('./' + $__InputFile) }
        $__InputFileDir = Split-Path -Path $__InputFile
        Set-Location -Path (Convert-Path $__InputFileDir)
    }
    $__InputFileName = Split-Path -Leaf $__InputFile

    #出力ディレクトリのフルパスを取得(出力ディレクトリが存在しないと取得に失敗するので存在しない場合は作成)
    $__FullOutputDir = ''
    if (!(Test-Path $__OutputDir)) {
        New-Item $__OutputDir -ItemType Directory | Out-Null
    }
    if (Split-Path $__OutputDir -IsAbsolute) {
        $__FullOutputDir = $__OutputDir
    } else {
        $__FullOutputDir = Convert-Path $__OutputDir
    }

    if ($__WatchMode) {
        #ウォッチモード
        #オプションの無効化
        $__Intermediate = $false
        $__StdOutput = $false

        #更新日時の取得
        $__UpdateCheckHash = @{}
        &$__UpdateCheck $__UpdateCheckHash $false

        while ($true -eq $true) {
            #更新確認
            &$__UpdateCheck $__UpdateCheckHash $true

            #指定秒数待つ
            Start-Sleep $__WatchTime
        }
    } else {
        #通常モード
        #変換処理開始
        &$__UpdateCheck @{} $true
    }
}
```

# ドキュメントの叩き台

# PowerShellテンプレートエンジン

PowerShellで実装したテンプレートエンジンです。

C#等のC言語系プログラミング言語やPowerShellスクリプトにおいてそのままコンパイル可能な状態を維持したまま、前処理や変数の埋め込みが行えるようデザインされています。

# インストール・実行

本スクリプトはバッチファイルとしてコマンド実行及び、ps1ファイルとしてインポートしてPowerShell関数(コマンド)として実行のどちらもできるようになっています。

batファイルとして使う場合は適当なフォルダに入れてPATHを通してください。

```bat
PSTR.bat input.cs
```

```powershell
. 'PSTR.ps1'
Invoke-PSTemplate -InputFile input.cs
```

# オプション

# PowerShellテンプレートエンジン

PowerShellで実装したテンプレートエンジンです。

C#等のC言語系プログラミング言語やPowerShellスクリプトにおいてそのままコンパイル可能な状態を維持したまま、前処理や変数の埋め込みが行えるようデザインされています。

# インストール・実行

本スクリプトはバッチファイルとしてコマンド実行及び、ps1ファイルとしてインポートしてPowerShell関数(コマンド)として実行のどちらもできるようになっています。

batファイルとして使う場合は適当なフォルダに入れてPATHを通してください。

```bat
PSTR.bat input.cs
```

```ps
. 'PSTR.ps1'
Invoke-PSTemplate -InputFile input.cs
```

# オプション

### -InputFile [ファイル名] 

前処理するファイルを指定します。ワイルドカードで複数のファイルを対象にすることができます。
指定したファイルのパスをカレントディレクトリにして処理を開始します。

### -BindScript [ファイル名]

前処理や変数展開のために使用するPowerShellスクリプトファイルを指定します。
中間コードの一番最初に実行されます。

### -OutputDir [ディレクトリ]

変換後のファイルを出力するディレクトリを指定します。
何も指定しなければ```./out```ディレクトリに出力します。

### -Encoding [文字コード名]

変換後のファイルの文字コードを文字列で指定します。ファイルに出力する時のみ有効です。

何も指定しなければ```UTF-8(BOMなし)```で出力します。

.NETの仕様上```UTF-8```を指定するとBOM付きで出力しますので注意してください。

### -StdOutput

```-StdOutput```を指定すると変換後のコードをファイルではなく標準出力に出力します。

### -Intermediate

```-Intermediate```を指定すると標準出力に変換用の中間コード(PowerShell)を出力します。

### -CheckSubDir

```-CheckSubDir```を指定すると、サブディレクトリ内の指定ファイルも変換の対象とします。
ただし、```-OutputDir```内の(サブ)ディレクトリ・ファイルは変換対象になりません。

### -WatchMode

```-WatchMode```を指定するとウォッチモードとなり、一定間隔で対象ファイルの更新日時を確認して更新されていれば変換処理を実行します。
ウォッチモードでは```-StdOutput```や```-Intermediate```オプションは無効となり、常にファイルに出力します。

### -WatchTime [時間(秒)]

ウォッチモードで更新日時を確認する間隔を秒単位で指定します。指定しなければ5秒ごとにチェックします。

# テンプレート展開形式

テンプレート展開形式にはCコメント形式、タグ(Powershellコメント)形式、Powershell変数展開形式の3種類があります。

## Cコメント形式

C言語系のコメント内で展開する変数及びPowershellコードの設定をします
これによってコンパイル可能な状態を維持したままテンプレートを利用できます。

### ファイル取り込み

外部ファイルを取り込むことができます。外部ファイル内のテンプレート構文も有効です。
同じファイルを二回以上取り込むことはできません。

```Csharp
//#include "[ファイル名]"
```

### Powershell変数展開

C言語のブロックコメント内にPowershellの変数展開構文を設定できます。
展開時にブロックコメントは削除され出力コードには出力されません。
```${}```や```$()```といった```$```がある構文のみ有効で、```$```なしの```{}```等は使えません。

```csharp
/*${[変数名]}*/
/*$([コマンドまたは関数名])*/
```
Cコメント形式では変数名や型名の展開に使うとき等、
何かしら名前や値がないと構文エラーになる場所でも使えるよう、ダミーの名前や値を設定できます。

```csharp
/*${test}*/dummy_name
```
```*/```の直後(スペース等を挟まない)に英数字及び_@(上記の場合```dummy_name```)をつけると
展開時に消去して出力コードには反映されません。

### Powershellコード埋め込み(単一行)

C言語(C++言語)の行コメントに```$```を付け```//$```とすることで前処理用のPowershellコードを埋め込事が出来ます。

```csharp
//$[Powershellコード]
```

一般的なテンプレートのように```if```や```for```等の制御構文を埋め込んで出力を制御することもできます。

```csharp
//Hello Worldを10回出力
//$ for ($i = 1; $i -le 10; $i++) {
Console.WriteLine("Hello World");
//$ }
```

### Powershellコード埋め込み(複数行)

非推奨ですが、複数行のPowershellコードを埋め込む事が出来ます。開始(```/*<#$```)と終了(```$#>*/```)は必ず行の先頭に置く必要があります。

```csharp
/*<#$

複数行のPowershellコード

$#>*/
```


## タグ形式(Powershellコメント形式)

特殊なタグのような形式で展開する変数及びPowershellコードの設定ができます。
これによってPowershellで実行可能な状態を維持したままテンプレートを利用できます。
またHTMLにおいても展開しなければ無効なタグとしてブラウザに何も出力しない状態となります。

### ファイル取り込み

外部ファイルを取り込むことができます。外部ファイル内のテンプレート構文も有効です。

```powershell
<#include "[ファイル名]"#>
```

### Powershell変数展開

Powershellのブロックコメント(```<# #>```)内にPowershellの変数展開構文を設定できます。
展開時にブロックコメントは削除され出力コードには出力されません。
```${}```や```$()```といった```$```がある構文のみ有効で、```$```なしの```{}```等は使えません。

```powershell
<#${[変数名]}#>
<#$([コマンドまたは関数名])#>
```

C形式のダミーの変数の設定はできません。

### Powershellコード埋め込み(単一行)

Powershellのブロックコメント(```<# #>```)内に前処理用のPowershellコードを埋め込事が出来ます。
変数展開と区別するため、```<#$```の直後に```{```及び```(```を入れることはできません。必要な場合はスペースを挟んでください。

```powershell
<#$[powershellコード]#>
```
一般的なテンプレートのように```if```や```for```等の制御構文を埋め込んで出力を制御することもできます。

```powershell
#Hello Worldを10回出力
<#$ for ($i = 1; $i -le 10; $i++) { #>
Write-Host 'Hello World'
<#$ } #> 
```
### Powershellコード埋め込み(複数行)

非推奨ですが、複数行のPowershellコードを埋め込む事が出来ます。開始(```<#$```)と終了(```$#>```)は必ず行の先頭に置く必要があります。

```powershell
<#$

複数行のPowershellコード

$#>
```

## Powershell変数展開形式

通常のテキストファイル等、構文エラーを特に気にする必要がないデータの場合は
```{}```や```${}```及び```$()```といったPowerShellの変数展開構文をそのまま使うことができます。
この形式でファイル取り込みやPowershellコード埋め込みはできません。

https://docs.microsoft.com/ja-jp/powershell/scripting/learn/deep-dives/everything-about-string-substitutions?view=powershell-7.2

# 変数について

名前衝突を可能な限り避けるよう中間コードから参照できるスコープ内のテンプレートエンジンに関わる変数には全て二重アンダーバー(__)が先頭についています。
このテンプレートエンジンに使用している二重アンダーバー付きの変数の内容が書き換えられた場合の動作は未定義です。
テンプレート側で二重アンダーバー付きの変数を参照・代入しないようにしてください。