2021-02-08

2021-01-31

技術ノート

eyecatch
小ネタです。PowerShell にはユーザーが定義した変数をカジュアルにクリーンアップする方法が用意されていません。そこで今回は、この操作をできるだけ楽に実行する方法を考えます。

はじめに

言いたいことはタイトルのとおりです。スクリプトを書くに当たっていろいろ試行錯誤していると、その過程で一時的な変数を設定したり、変数の命名が気に入らなくなって別名で変数を作り直したり、そんな感じでいつのまにかゴミが溜まって汚くなってしまい、なにか処理をしたときにソイツらが邪魔をして期待通りの結果が返ってこない、なんてことはよくあります。そうなると、予約変数や自動変数を除いた自作変数だけを一括削除したいな、なんかいい方法ないかな、と考えはじめますよね。しかし PowerShell にはそんな方法は用意されていません。そこで「いかに自作変数だけを簡単に確実にクリーンアップするか」ということを考えるようになります。たぶん。今回はそれを私なりに実現した方法を紹介します。免責事項ですが、ここで紹介しているのはあくまで私の環境において現時点で正常に動作しているように見える手法であり、私が気づいていないなんらかの副作用が実は眠っている可能性は十分にあります。この点に関しては予めご了承ください。

$profile を作成する

結論からいうと、$profileによる PowerShell セッション開始時のスクリプト自動読み込みで実現します。まずはprofile.ps1を作成します。今回powershell.exepowershell_ise.exeで設定を分ける必要はないので、CurrentUserAllHostsにプロファイルを作成します。(PowerShell 6以降ならpwsh.exeですが、これは後述します)$profileについての詳細は、古い記事ですがこちらを参考にしてください。


if (-not (Test-Path -Path $profile.CurrentUserAllHosts))
{
    New-Item -Path $profile.CurrentUserAllHosts -ItemType File
}

以下の空ファイルが作成されているはずです。


# WindowsPowerShell 5.1 までの場合
${HOME}\Documents\WindowsPowerShell\profile.ps1

# PowerShell 6以降の場合
${HOME}\Documents\PowerShell\profile.ps1

$profile を編集する

profile.ps1に以下を記述します。


New-Variable -Name 'DefaultVariableSet' -Value $(((Get-Variable).Name | ForEach-Object { if ($_.Length -eq 1){ [System.String]::Concat('`', $_) } else { $_ } }) + 'DefaultVariableSet') -Option Constant
function global:gva { Get-Variable    -Name * -Exclude $DefaultVariableSet -Scope Script }
function global:rva { Remove-Variable -Name * -Exclude $DefaultVariableSet -Scope Script }

少し込み入ったことをしているので、段階を分けて説明します。まず1行目ですが、レベル1を以下とします。


New-Variable -Name 'DefaultVariableSet' -Value (Get-Variable).Name

ここではまずGet-Variableした変数の名前を取得し、それを単一の変数'DefaultVariableSet'に代入しています。実行すると以下のように、PowerShell セッション開始直後の変数の状態をキャプチャできます。


PS C:\WINDOWS\system32> New-Variable -Name 'DefaultVariableSet' -Value (Get-Variable).Name
PS C:\WINDOWS\system32> $DefaultVariableSet

$
?
^
args
ConfirmPreference
ConsoleFileName
DebugPreference
Error
ErrorActionPreference
ErrorView
ExecutionContext
false
FormatEnumerationLimit
HOME
Host
InformationPreference
input
MaximumAliasCount
MaximumDriveCount
MaximumErrorCount
MaximumFunctionCount
MaximumHistoryCount
MaximumVariableCount
MyInvocation
NestedPromptLevel
null
OutputEncoding
PID
profile
ProgressPreference
PSBoundParameters
PSCommandPath
PSCulture
PSDefaultParameterValues
PSEdition
PSEmailServer
PSHOME
psISE
PSScriptRoot
PSSessionApplicationName
PSSessionConfigurationName
PSSessionOption
PSUICulture
psUnsupportedConsoleApplications
PSVersionTable
PWD
ShellId
StackTrace
true
VerbosePreference
WarningPreference
WhatIfPreference

ここから少し発展させます。レベル2です。


New-Variable -Name 'DefaultVariableSet' -Value $((Get-Variable).Name | ForEach-Object { if ($_.Length -eq 1){ [System.String]::Concat('`', $_) } else { $_ } })

-Value パラメータの値は、見やすいように改行すると以下の形になります。


$(
    (Get-Variable).Name | ForEach-Object { 
        if ($_.Length -eq 1)
        { 
            [System.String]::Concat('`', $_) 
        }
        else
        {
            $_
        }
    }
)

これは何をしているかというと、キャプチャした変数一覧の中で文字数が1文字の変数に対して、先頭にエスケープ文字列である'`'をくっつけています。対象の'$','?','^'は以下の通り、正規表現として解釈が可能な文字列であることがわかります。

変数 正規表現
$ 直前の文字が行の末尾にある場合にマッチ
? 直前の文字が0個か1個の場合にマッチ
^ 直後の文字が行の先頭にある場合にマッチ

これがハマりポイントだったのですが、コイツらがおそらく正規表現として解釈され、以下のようなことをした場合に$aなどの1文字の変数だけが取得できない事象が発生しました。動作を確認した限りでは、とりわけ'?'が悪さをしているものと推測されます。


Get-Variable -Name * -Exclude $DefaultVariableSet

どんな事象かは、以下の動画を見ていただくとわかると思います。

続いてレベル3です。


New-Variable -Name 'DefaultVariableSet' -Value $(((Get-Variable).Name | ForEach-Object { if ($_.length -eq 1){ [System.String]::Concat('`', $_) } else { $_ } }) + 'DefaultVariableSet')

変数'DefaultVariableSet'の値に自分自身を追加しています。これは、以下のようにしたときに自分自身が削除されてしまうのを防ぐためです。


Remove-Variable -Name * -Exclude $DefaultVariableSet

最後、レベル4です。


New-Variable -Name 'DefaultVariableSet' -Value $(((Get-Variable).Name | ForEach-Object { if ($_.Length -eq 1){ [System.String]::Concat('`', $_) } else { $_ } }) + 'DefaultVariableSet') -Option Constant

-OptionパラメータにConstantを渡して定数化し、簡単には削除できないようにしました。ここまでで、PowerShell セッション開始時の変数の状態を適切な形でキャプチャすることができました。続いて2行目です。


function global:gva { Get-Variable -Name * -Exclude $DefaultVariableSet -Scope Script }

gvaと入力すると、変数'DefaultVariableSet'に入っている変数以外の変数、つまりユーザーが作成した変数が表示されます。ポイントは、スコープをきっちり指定することです。指定しないとLocalスコープとなり、スコープの範囲が呼び出し元に届かず、呼び出し元のスコープつまり現在の PowerShell セッションで設定した変数が取得できません。このため、明示的にScriptスコープを指定しています。続いて3行目です。


function global:rva { Remove-Variable -Name * -Exclude $DefaultVariableSet -Scope Script }

手法は完全に同じで、Get-VariableRemove-Variableになっただけです。rvaと入力すると、現在のセッションでユーザーが定義した変数だけをすべて削除します。長くなりましたが、これで本エントリで目標にしていた「一撃で自作変数のみをすべて削除する」が達成できました。-ErrorAction Ignoreでエラーを握りつぶすといったこともないため、比較的クリーンな手法で実現できたと言えます。

Windows PowerShell ISE のスニペットでやってみる

こんな感じで運用していると「rva と打つことすらめんどくせえ」と思いはじめます。正常な思考です。そこでショートカットキーを割り当てられないかと考えはじめます。ふだん Windows 標準の環境で簡単なスクリプトを作る際には、まだまだ ISE を使うことは多いと思います。ISE ではスニペットを登録することで比較的便利にすることができました。以下のコマンドでスニペットを登録します。


New-IseSnippet -Title '!CleanupVariable' -Description 'すべてのユーザー定義変数を削除します。' -Text 'Remove-Variable -Name * -Exclude $DefaultVariableSet'

ISE スニペットが生成されました。実体は以下です。


# WindowsPowerShell 5.1 までの場合
${HOME}\Documents\WindowsPowerShell\Snippets\!CleanupVariable.snippets.ps1xml

# PowerShell 6以降の場合
${HOME}\Documents\PowerShell\Snippets\!CleanupVariable.snippets.ps1xml

ポイントは、-Titleの先頭に!などの記号を入れ、スニペット一覧のいちばん上に表示されるようにすることです。Ctrl+Jキーでスニペット一覧が開きますが、いちばん上に目的のスニペットが存在していると最初から選択状態になるので、そのままEnterを押すことでスニペットがペーストされます。つまりCtrl+J => Enter => Enterで変数のクリーンアップが可能です。rvaと打つより少し楽です。

Visual Studio Code のスニペットでやってみる

本命の Visual Studio Code では、残念ながらさほど便利にはなりませんでした。もっといい感じになるかもしれませんが、いったん紹介します。まずユーザースニペットにコマンドを記述する方法が真っ先に思い浮かびます。以下のパスにjsonで記述します。


${HOME}\AppData\Roaming\Code\User\snippets\powershell.json

中身はこんな感じでしょうか。


{
    "Get All User Valiables": {
        "prefix": "gva",
        "body": [
            "Get-Variable -Name * -Exclude $DefaultVariableSet"
        ],
        "description": "Get All User Valiables"
    },
    "Cleanup All User Valiables": {
        "prefix": "rva",
        "body": [
            "Remove-Variable -Name * -Exclude $DefaultVariableSet"
        ],
        "description": "Cleanup All User Valiables"
    }
}

こうすることで、編集中の.ps1ファイル上であれば、gvarvaを呼び出すことができます。また別解として、ショートカットキーを割り当てる方法もあります。keybindings.jsonに以下を記述します。


[
    {
        "key": "ctrl+alt+g",
        "command": "editor.action.insertSnippet",
        "when": "editorTextFocus",
        "args": {
          "snippet": "Get-Variable -Name * -Exclude $DefaultVariableSet"
        }
    },
    {
        "key": "ctrl+alt+r",
        "command": "editor.action.insertSnippet",
        "when": "editorTextFocus",
        "args": {
          "snippet": "Remove-Variable -Name * -Exclude $DefaultVariableSet"
        }
    }
]

こちらのほうが幾分か楽かもしれません。しかし、どちらの場合もまだ解決できていない課題があります。それは、エディタ上では動作するがターミナル上では動作しないという点です。これは Visual Studio Code の仕様で仕方がないっぽいのですが、Windows 環境の Visual Studio Code で PowerShell を開発する場合のターミナルは PowerShell Integrated Console を利用することが多いと思うので、なんとも痒いところに手の届かない感じになってしまいました。ショートカットキー割り当てで、エディタ上に出したスニペットをターミナルにカットアンドペーストするといった方式も考えられますが、それをやるくらいならエディタ / ターミナル間のカーソル移動だけにショートカットキーを割り当て、あとはgvarvaを入力するようにしたほうが無理がないでしょう。

おまけ

以下のように、WindowsPowerShell 5.1 までと PowerShell 6以降ではユーザーディレクトリが異なります。


# WindowsPowerShell 5.1 までの場合
${HOME}\Documents\WindowsPowerShell

# PowerShell 6以降の場合
${HOME}\Documents\PowerShell

この両方のディレクトリに対して、モジュールやスニペットやプロファイルを別々に管理するのは、そうせざるをえない要件がある場合は有用ですが、管理としては煩雑になってしまうため、私は以下のようにシンボリックリンクを貼って運用しています。そして、できるだけ 5.1 でも6以降でも動くようにスクリプトやモジュールを書くようにしています。


New-Item -Path "${env:USERPROFILE}\Documents" -Name "PowerShell" -ItemType SymbolicLink -Value "${env:USERPROFILE}\Documents\WindowsPowerShell"

おわりに

小ネタのわりに、けっこう調べて書きました。限りなくニッチな内容ですが、誰かのお役に立てればなによりです。余談ですが、今回初めて動画を導入しようとしていろいろ試行錯誤していました。DB 構成の変更が必要なことに気づいて結局は保留にしたのですが、Gaming Bar と Windows フォトって使いこなすと便利なんだなと思いました。

追記 :

動画フィールドを実装しました。