Powershellでの高度な置換


## Regexクラスを使う

Powershellにはいくつかの置換方法が用意されている。

<strong>1. `"文字列".Replace("old","new")`</strong>

一番シンプルで高速。正規表現は使えない。

```powershell:String.Replace
"Hello World!".Replace("World","Qiita")
# Hello Qiita!
```

  
<strong>2. `-replace`演算子</strong>

正規表現が使える。

```powershell:replace演算子
"Hello World!" -replace "\s","-"
# Hello-World!
```
キャプチャができる。

```powershell:replace演算子
"I am a programmer." -replace "\ba (\w+)","not a good `$1"
# I am not a good programmer.
```

ただしキャプチャした文字はそのまま使う以外に操作できない。

  
<strong>3. `Regex`クラスの`Replace()`</strong>

マッチした値を変数として扱える。
  
```powershell:Regexクラス
$s = "this_is_camelcase"
[regex]::Replace($s, "_(\w)", { $args.groups[1].value.toupper() }) #
# thisIsCamelcase
```


3つ目はC#ではよく使われてるであろうラムダ式渡すやつなんですが、Powershellでもスクリプトブロックを渡すことで同じメソッドを使えますよってことを言いたいだけです。この記事はこの3つ目の使い方をツラツラと書き散らかします。

## マッチした値になんらかの操作をして置換する

### マッチ部分を大文字に

```powershell:大文字に変換
[regex]::replace("abcd3e-34a", "\w+(?=-\w+)", { $args.value.toUpper() }) #
# abcd3e-34a → ABCD3E-34a
```

`(?=pattern)`は肯定的先読み。直後にpatternがある場所に一致する。
スクリプトブロック内の`$args`の型は`System.Text.RegularExpressions.Match`。
(本当は`$args[0]`とすべきだろうが、どうせ引数は一つしか入ってこないので省略。)
`$args.value`は一致した部分の文字列。
`ToUpper()`で大文字に変換する。

### マッチした数値部分の0埋め

```powershell:パディング
[regex]::replace("abcd3e-34a", "(?<=\w+-)\d+(?=\w+)", { $args.value.padleft(5,'0') }) #
# abcd3e-34a → abcd3e-00034a
```

`(?<=pattern)`は肯定的後読み。直前にpatternがある場所に一致する。
`PadLeft()`で文字埋めできる。

### 数値に+10

```powershell:計算
[regex]::replace("abcd3e-34a", "(?<=\w+-)\d+(?=\w+)", { 10 + $args.value }) #
# abcd3e-34a → abcd3e-44a
```

数字にマッチすれば計算もできる。計算順序を気にしないでいいように`[int]$args.value`でキャストした方が確実かもしれない。


### 和暦を西暦に

```powershell:日時
$s = "おじいさんは明治43年生まれです。"

[regex]::Replace($s, "(明治|大正|昭和|平成)[\d元]+年",
    { Get-Date $args.value.replace("元",1) -format "yyyy年" } )

# おじいさんは1910年生まれです。
```

`Get-Date -format`を使う。(`[datetime]::parse(value).toString(yyyy年)`でもいい。)
西暦→和暦は日付も関係するのでパス。

### 全角数字→半角数字
```powershell:全角数字を半角に
$s = "２０１８年１０月８日の降水確率は２０％です。"

[regex]::replace($s,"[０-９]", { $args.value[0] - 65248 -as "char" })

# 2018年10月8日の降水確率は20％です。
```
全角と半角の文字コードの差を引く。
`"文字列"[0]`で`char`型の値になる。`-as "char"`も同様。


## キャプチャ

### 円をドルに

```powershell:キャプチャ
$s = "PC本体が347831円、モニタは12345円、その他周辺機器が5923円でした。"

[regex]::Replace($s, "(\d+)円", { "{0:0,0.00}ドル" -f (1/112.21 * $args.groups[1].value) }) #

# PC本体が3,099.82ドル、モニタは110.02ドル、その他周辺機器が52.78ドルでした。
```

グループ化してキャプチャした値は`$args.groups[index]`でとれる。
`{0:0,0.00}`は数値の書式指定文字。ここでは桁区切りで小数点第二位まで表示する指定。


## 条件別
マッチした値によって置換後の文字をかえる。
### 数値の範囲による場合分け

```powershell:switchで分岐
$s = "リサの体重は40kgです。メアリーの体重は52kgです。キャサリンの体重は87kgです。"

[regex]::replace($s, "(\d+)kg", {
    switch($args.groups[1].value) {
        {$_ -lt 45} { "とても軽い" }
        {$_ -gt 70} { "秘密" }
        default { $args.value } }
    })

# リサの体重はとても軽いです。メアリーの体重は52kgです。キャサリンの体重は秘密です。
```
マッチした数字の範囲によって場合分け。`switch`文の条件にはスクリプトブロックも使える。




### CSS色指定方法の変換

cssの色指定を`rgb()`に変える。

```powershell:色指定変換
$s = @"
a {
    color:#ff7f00;
    background:#235;
}
"@

[regex]::Replace($s,"#([\da-f]{6}|[\da-f]{3})", {
        $v = $args.groups[1].value
        if($v.length -eq 6) {
            "rgb({0},{1},{2})" -f ([regex]::Matches($v,"..").value | foreach { [Convert]::ToInt32($_, 16) })
        } else {
            "rgb({0},{1},{2})" -f ([char[]]$v | foreach { 17 * [Convert]::ToInt32($_, 16) })
        }
    } , "ignorecase")

# a {
#     color:rgb(255,127,0);
#     background:rgb(34,51,85);
# }
```

3桁で指定する場合と6桁で指定する場合があるので処理を分ける。
n文字毎に分割するには`[regex]::Matches($v,".{n}").value`が手っ取り早い。
オプションの`ignorecase`は大文字小文字を区別しない。

## 辞書による置換

ハッシュテーブルを作って、対応する値に変換する。


### ハッシュテーブルで置換1

```powershell:ハッシュテーブルの活用

$s = "藪から棒に、清水の舞台から飛び降りる。"

$h = @{
    棒 = "スティック"
    舞台 = "ステージ"
    飛び降りる = "ダイブする"
}

[regex]::replace($s, ($h.Keys -join "|"), { $h[$args.value] }) #

# 藪からスティックに、清水のステージからダイブする。
```

### ハッシュテーブルで置換2
```powershell:ハッシュテーブルで置換
$s = "昨日から雨が続いていますが、明日は全国的に晴れるでしょう。"

$ht = @{ 昨日 = -1; 今日 = 0; 明日 = 1 }
[regex]::replace($s,($ht.Keys -join "|"), { (date).AddDays($ht[$args.value]).ToString("d日(ddd)") })

# 24日(水)から雨が続いていますが、26日(金)は全国的に晴れるでしょう。
```

## キャプチャスタックを使う
一般的にグループの繰り返しでは最後にキャプチャしたものしか使えないが、.Netでは以下のようにキャプチャしたものが全てスタックされる。これを利用する。


```powershell:キャプチャスタック
[regex]::Match("22-33-44", "(?:(\d+)|-)+").groups[1].captures

# Index Length Value
# ----- ------ -----
#     0      2 22   
#     3      2 33   
#     6      2 44   
```

### 算数

繰り返し回数が不定でさらにその値をまとめて処理する必要がある時はキャプチャスタックが便利。

```powershell:足し算引き算の結果を入力
$s = @"
3たす4ひく1は？です。
2たす1たす100ひく40たす23は？です。
"@

$calc = @{
    たす = { $args[0] + $args[1] }
    ひく = { $args[0] - $args[1] }
}

[regex]::Replace($s,"(\d)(?:($($h.Keys -join "|"))(\d+))+は？", {
    
    $acum   = $args.groups[1].value -as "int"              # 3
    $opes   = $args.groups[2].captures.value               # たす,ひく
    $digits = $args.groups[3].captures.value -as "int[]"   # 4,1
    
    foreach ($i in 0..($digits.length -1)) {
        $acum = invoke-command $calc[$opes[$i]] -Args $acum,$digits[$i] 
    }
    $args.value.Replace("？", $acum)
})

# 3たす4ひく1は6です。
# 2たす1たす100ひく40たす23は86です。
```

## 入れ子になった括弧の中の置換

括弧のようなネストされるものを扱う時、グループ定義の均一化というものを使う。
上の例のように.NETにはグループ文字列の繰り返しや名前付きキャプチャなどでキャプチャしたものをスタックする機能がある。

名前付きキャプチャは`(?<group1>expression)`とすることで同じグループとしてキャプチャできる（`<group1>`は`'group1'`と書いてもよい）。

```powershell:名前付きキャプチャ
$s = "108円と450円です"
$match = [regex]::Match($s,"(:?(?<group1>\d+円)|\D)*")
$match.Groups["group1"].Captures

# Index Length Value
# ----- ------ -----
#     0      3 108円
#     5      3 450円
```

グループ定義の均一化とは`(?<-group1>expression)`とすることで`expression`にマッチした時、`group1`グループのキャプチャスタックをポップする機能をいう。

```powershell:グループ定義の均一化
$s = "108円と450円、じゃなくて430円です"
$match = [regex]::Match($s,"(:?(?<group1>\d+円)|(?<-group1>じゃなくて)|\D)*")
$match.Groups["group1"].Captures

# Index Length Value
# ----- ------ -----
#     0      4 108円 
#    15      4 430円
```

もし`group1`のスタックが空の時は全体が失敗する。
つまり開き括弧を`group1`でキャプチャしておけば、閉じ括弧が多い場合などには失敗とすることができる。
これだけだと開き括弧が過多の場合には通ってしまうが、末尾に条件付きパターンを使って`(?(group1)?!")`とすることで`group1`スタックが空でない場合に失敗させることができる。
これらによって開き括弧と閉じ括弧の整合性をチェックすることができる。

さらに`(?<group2-group1>expression)`とすると`group1`のスタックをポップした後に、ポップした文字列と`expression`の間の文字列を`group2`グループにスタックできる。

```powershell:括弧の中を取得
$s = "あいう(えお(かきく)けこ)さし(すせ)そ"

$regex = [regex]"^(?:[^()]|(?<open>[(])|(?<content-open>[)]))*(?(open)(?!))$"
$regex.Match($s).groups["content"].captures

# Index Length Value    
# ----- ------ -----    
#     7      3 かきく      
#     4      9 えお(かきく)けこ
#    17      2 すせ
```

### 最上位の括弧の中を置き換える

```powershell:最上位の括弧の中をhelloに置換
$s = "a(bc(def)ghi(jk(lmn))opqr)st(uvw)xyz"

[regex]::Replace($s, "(?:[^()]|(?'open'[(])|(?'content-open'[)]))*", { 

    $result = $args.value
    $end = 0
    foreach($content in $args.groups[2].captures | sort index) {
        $i = $content.index + $content.length
        if($i -gt $end) {
            $end = $i
            $result = $result.Replace("($content)","(hello)")
        }
    }
    $result
})

# a(hello)st(hello)xyz
```

上の例は括弧の整合性のチェックが目的ではないので末尾での開き括弧過多のチェックは省略してる。
まず`open`グループで開き括弧をキャプチャする。次に`content-open`で閉じ括弧にマッチした時、`open`グループのキャプチャスタックをポップし、同時にそこから閉じ括弧までの文字列を`content`グループにスタックする。つまりそれぞれの括弧の中の文字はすべて`content`グループにスタックされる。
スクリプトブロックではスタックされた文字列のインデックス情報から最上位のものだけを抽出して置き換えている。

（名前付きキャプチャしたものは`$args.groups["content"]`で得られると思ったが`[regex]::Replace`の引数のスクリプトブロック内ではなぜかできなかったのでインデックスで指定してる。バグ？）

## 連番

### マッチ部分に連番を付加

```powershell:assign_number
$s = @"
PDCA
 ・ 計画
 ・ 実行
 ・ 評価
 ・ 改善
"@

$i = 1
[regex]::Replace($s, "・\s*(\w+)", { "{0}. {1}" -f $script:i++, $args.groups[1].value})  #
# PDCA
#  1. 計画
#  2. 実行
#  3. 評価
#  4. 改善
```

スクリプトブロック内でカウンタをインクリメントする。
`$script:i`はスクリプトスコープの`$i`を指定している。


## パターンを使いまわす

同じパターンを何度も使うときはパターン文字列を`Regex`オブジェクトにキャストすると少しすっきりする。

```powershell
$s = "i have an apple."

$pattern = [regex]"\ba\w*"
$pattern.Replace($s, "Aword")

# i have Aword Aword.
```

## 最後に
速度的にはあまり速いとは言えないんで大量のファイルを置換するのには向かないかもしれない。