PowerShellで、PowerShellの解析ツールを作る ── 正規表現ではなく、ASTでスクリプトを読む

· 更新日: · · PowerShell, AST, 静的解析, 開発ツール

更新履歴(初版のみ・2026年09月13日公開)
初版公開

PowerShellのスクリプトをまとめて調べて、Write-Hostを呼んでいる場所を一覧にしたいとします。単語を検索するだけなら簡単ですが、次の3行が全部引っ掛かります。

$source = @'
# Write-Host は画面への表示に使う
$message = 'Write-Host'
Write-Host 'こんにちは'
'@

探したいのは3行目だけです。1行目はコメント、2行目は変数に代入する文字列なので、どちらもWrite-Hostを呼んではいません。

PowerShell自身は、この違いを読み分けてコードを実行しています。その読み分けた結果を、こちらから取り出すこともできます。今回はコードを構文解析し、できたオブジェクトの中身を調べるところから始めます。追加モジュールは使いません。1

上の@' ... '@は単一引用符のヒアストリングです。中の3行を今は実行せず、$messageも展開しない文字列として、$sourceに入れています。2

1. パーサーが返すものを表示する

まず、$sourceParseInputへ渡します。

$tokens = $null
$parseErrors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseInput(
    $source, [ref] $tokens, [ref] $parseErrors)

if ($parseErrors.Count -gt 0) {
    throw $parseErrors[0].Message
}

$ast.GetType().Name
ScriptBlockAst

$astに入ったのは、文字列でも、スクリプトの実行結果でもなく、ScriptBlockAst型のオブジェクトです。これが入力したコード全体を表しています。ParseInputは、戻り値としてこのオブジェクトを返すほか、[ref]で渡した変数へトークンと構文エラーを返します。1

ASTは「抽象構文木」の略です。名前から特別なデータ構造を想像するかもしれませんが、PowerShellから見ると、まずはプロパティとメソッドを持ったオブジェクトです。そのプロパティをたどると、代入文やコマンド呼び出しを表す別のオブジェクトが出てきます。3

2. 3行のコードは、どんなオブジェクトになったか

今回のようにbeginprocessendを明示していないコードでは、通常の文はEndBlock.Statementsに入っています。種類と元のコードを並べてみます。4

$statements = $ast.EndBlock.Statements
$statements | ForEach-Object {
    [pscustomobject]@{
        Type = $_.GetType().Name
        Text = $_.Extent.Text
    }
}
Type                   Text
----                   ----
AssignmentStatementAst $message = 'Write-Host'
PipelineAst            Write-Host 'こんにちは'

2件です。コメントの行は、この文の一覧には入りません。コメント自体が必要なら、先ほど受け取った$tokensのほうから取得できます。3

2行目は代入を表すAssignmentStatementAstになりました。3行目はPipelineAstです。|を書いていなくても、コマンドが一つだけのパイプラインとして表されます。Extent.Textは、そのオブジェクトに対応する元のコードです。型名だけで分からなくなったときも、これを見ればどの部分の話か確かめられます。56

元のコードと構文オブジェクトの対応スクリプト全体のEndBlockに、2行目の代入と3行目のパイプラインが入り、後者にコマンド呼び出しが含まれます。ScriptBlockAstEndBlock代入文:2行目パイプライン:3行目CommandAst

図1: 3行目の呼び出しは、パイプラインの中にあります。

配列の添字は0からなので、$statements[1]が3行目のパイプラインです。そこから、先頭の要素を取り出します。

$pipeline = $statements[1]
$call = $pipeline.PipelineElements[0]

$call.GetType().Name
$call.Extent.Text
$call.GetCommandName()
CommandAst
Write-Host 'こんにちは'
Write-Host

CommandAstが見つかりました。元のコードを返すExtent.Textと、呼び出し名を返すGetCommandName()を、それぞれ同じオブジェクトに対して使っています。7

引数も含めた内訳は、CommandElementsに入っています。

$call.CommandElements | ForEach-Object {
    [pscustomobject]@{
        Type = $_.GetType().Name
        Text = $_.Extent.Text
    }
}
Type                        Text
----                        ----
StringConstantExpressionAst Write-Host
StringConstantExpressionAst 'こんにちは'

名前と引数は、どちらも文字列を表すノードです。それでもGetCommandName()が返すのはWrite-Hostのほうです。文字列の綴りだけを見て選んでいるのではなく、コマンド呼び出しの中で、名前に当たる要素を見ているからです。2行目の'Write-Host'は代入文の右辺にあり、そもそもこの呼び出しには属していません。78

これが、文字列検索と構文解析の違いです。同じ単語でも、それが入っている構造を調べれば用途を区別できます。

3. 添字でたどる代わりに、CommandAstを探す

中身は確認できました。ただ、実際のスクリプトで「2件目の文の先頭要素」と決め打ちするわけにはいきません。呼び出しは関数の中にも、ifの中にも、パイプラインの途中にもあります。

そのための検索メソッドがFindAllです。

$commands = @($ast.FindAll({
    param($node)
    $node -is [System.Management.Automation.Language.CommandAst]
}, $true))

$commands.Count
$commands[0].GetType().Name
$commands[0].Extent.Text
1
CommandAst
Write-Host 'こんにちは'

FindAllは構文木をたどり、各ノードを{ ... }の判定処理へ渡します。渡された$nodeの型を-isで調べ、CommandAstなら$trueを返す。そのノードが検索結果に残ります。最後の$trueは、入れ子の関数やスクリプトブロックの中も探す指定です。9

たとえばfunction Show-Message { Write-Host 'hello' }でも、関数の中までたどって呼び出しを取得します。関数を実行する必要はありません。

関数の中まで型を調べて検索するスクリプトに含まれる関数の本体までたどり、CommandAstに一致する呼び出しを検索結果へ含めます。スクリプト関数の定義関数の本体CommandAst型が一致:結果に残る

図2: 入れ子の深さをこちらで数えなくても、検索条件に合うノードを取得できます。

冒頭の3行では、添字でたどったものと同じ呼び出しが1件見つかりました。名前と位置を取り出せば、検索結果として使えます。

$commands | ForEach-Object {
    [pscustomobject]@{
        Name   = $_.GetCommandName()
        Line   = $_.Extent.StartLineNumber
        Column = $_.Extent.StartColumnNumber
    }
}
Name       Line Column
----       ---- ------
Write-Host    3      1

行と列は1始まりです。名前も位置も同じノードが持っているため、後から別の文字列検索で行を探し直す必要はありません。6

4. 変数経由の呼び出しはどう見えるか

ここで、解析対象を少し変えます。名前を直接書く場合に加え、呼び出し演算子&に文字列や変数を渡す場合も入れます。

$source = @'
Write-Host 'direct'
& 'Write-Host' 'quoted'
$command = 'Write-Host'
& $command 'variable'
'@

$ast = [System.Management.Automation.Language.Parser]::ParseInput(
    $source, [ref] $tokens, [ref] $parseErrors)
if ($parseErrors.Count -gt 0) { throw $parseErrors[0].Message }

$commands = @($ast.FindAll({
    param($node)
    $node -is [System.Management.Automation.Language.CommandAst]
}, $true))

$commands | ForEach-Object {
    [pscustomobject]@{
        Line = $_.Extent.StartLineNumber
        Name = $_.GetCommandName()
        Text = $_.Extent.Text
    }
}
Line Name       Text
---- ----       ----
   1 Write-Host Write-Host 'direct'
   2 Write-Host & 'Write-Host' 'quoted'
   4            & $command 'variable'

4行目もCommandAstとして見つかっています。ただし、GetCommandName()の戻り値は$nullです。

人間がこの4行を読めば、直前の代入から$commandの値を推測できます。しかし、このメソッドは変数への代入をさかのぼって値を計算しません。コードに名前が直接書かれている場合と違い、このAPIだけでは名前を取り出せないのです。7

同じ呼び出しノードでも名前の取得結果が違う名前が文字列の呼び出しではWrite-Hostを取得でき、変数の呼び出しでは名前がnullになりますが、両方とも呼び出しの位置を持ちます。CommandAst名前の要素が文字列名前の要素が変数名前:Write-Host名前:null

図3: 名前が空欄でも、4行目に呼び出しが書かれていることは分かります。

ファイルを調べるツールでは、この違いをNameKind列に入れておきます。名前の文字列を取得できた行はStatic、取得できなかった行はUnresolvedです。名前のない行も残しておけば、人が確認すべき場所を取りこぼさずに済みます。

5. ファイルからWrite-Hostの場所を取り出す

文字列ではなく.ps1を読むときは、ParseInputの代わりにParseFileを使います。ASTの読み方は変わりません。10

これまでの処理をまとめたGet-ScriptCommandを、記事末尾の完成コード配布サンプルに用意しています。完成コードをGet-ScriptCommand.ps1として保存し、4章のヒアストリングの内側の4行をdemo.ps1として同じフォルダーに保存してください。配布サンプルには、この2ファイルが入っています。

そのフォルダーで実行します。

. .\Get-ScriptCommand.ps1
$calls = @(Get-ScriptCommand -LiteralPath .\demo.ps1)

$calls |
    Where-Object { $_.NameKind -eq 'Static' -and $_.Name -eq 'Write-Host' } |
    Format-Table Line, Column, NameKind, Name -AutoSize
Line Column NameKind Name
---- ------ -------- ----
   1      1 Static   Write-Host
   2      1 Static   Write-Host

これで、Write-Hostを名前に持つ呼び出しの場所が出せました。3行目の代入は入りません。冒頭で困っていた、コメントや普通の文字列が検索結果に混ざる問題を避けられています。

一方、4行目はこの絞り込みから外れます。そこに呼び出しがないのではなく、名前が未確定だからです。未確定の行は別に確認します。

$calls |
    Where-Object NameKind -eq 'Unresolved' |
    Format-Table Line, Column, NameKind, Name -AutoSize
Line Column NameKind   Name
---- ------ --------   ----
   4      1 Unresolved

Staticは「名前の文字列を取得できた」という印であって、そのコマンドが実在することや、どの実装が呼ばれるかの保証ではありません。たとえばechoechoのまま、Microsoft.PowerShell.Utility\Write-Hostは修飾付きの名前のまま返るので、上の完全一致検索には入りません。別名や修飾付き呼び出しも調査対象なら、検索条件をそれに合わせる必要があります。11

なお、ドットソースしたのは解析ツールのほうです。demo.ps1ParseFileで読むだけで、起動していません。

6. 検索結果は、実行履歴ではない

この検索は「コードにどう書かれているか」を調べています。使われない関数の中や、if ($false) { Write-Host ... }の中にも呼び出しは書かれているので、一覧に入ります。実行順序や回数は分かりません。

文字列についても、'Write-Host'"Today: $(Get-Date)"は扱いが違います。後者には式が埋め込まれているため、その内側のGet-Dateは見つかります。一方、普通の文字列に入っているコードを別のスクリプトとして再解析することはありません。2

[Console]::WriteLine(...)のような.NETメソッド呼び出しは、CommandAstとは別の種類のノードです。この一覧は全処理を網羅するものでも、安全性を証明するものでもありません。自分が管理するスクリプトの調査に使う道具です。実行時の別名や同名関数を確かめるには、そのスクリプトが動く環境も必要になります。1211

今回使った仕組みの中心は、GetType()で確かめたオブジェクトとExtent.Textです。別の構文を調べたくなったときも、短いコードをParseInputへ渡して、この二つを並べてみるところから始められます。既存ルールで品質を検査する目的なら、PSScriptAnalyzerのほうを使うとよいでしょう。

ファイル検索用の完成コード

以下は、5章で使ったGet-ScriptCommandの全体です。構文を読む部分は3章と同じで、その外側にファイルの取得、構文エラーの処理、複数ファイルの入力を加えています。

function Get-ScriptCommand {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [Alias('FullName')]
        [ValidateNotNullOrEmpty()]
        [string[]] $LiteralPath
    )

    process {
        foreach ($path in $LiteralPath) {
            $file = Get-Item -LiteralPath $path -Force -ErrorAction Stop
            if ($file -isnot [System.IO.FileInfo]) {
                throw "A file is required: $path"
            }

            $tokens = $null
            $parseErrors = $null
            $ast = [System.Management.Automation.Language.Parser]::ParseFile(
                $file.FullName, [ref] $tokens, [ref] $parseErrors)
            if ($parseErrors.Count -gt 0) {
                $first = $parseErrors[0]
                throw ('Parse error: {0}:{1}:{2} ({3})' -f $file.FullName,
                    $first.Extent.StartLineNumber,
                    $first.Extent.StartColumnNumber, $first.ErrorId)
            }

            $commands = $ast.FindAll({
                    param($node)
                    $node -is [System.Management.Automation.Language.CommandAst]
                }, $true)
            foreach ($command in ($commands | Sort-Object { $_.Extent.StartOffset })) {
                $name = $command.GetCommandName()
                $kind = if ($null -eq $name) { 'Unresolved' } else { 'Static' }
                [pscustomobject]@{
                    Path     = $file.FullName
                    Line     = $command.Extent.StartLineNumber
                    Column   = $command.Extent.StartColumnNumber
                    NameKind = $kind
                    Name     = $name
                }
            }
        }
    }
}

Get-Item -LiteralPathで実際のファイルを取得してから、FullNameParseFileへ渡します。draft[1].ps1のような名前もワイルドカードとして扱いません。ディレクトリーと存在しないファイルはエラーにします。

構文エラーがあれば、そのファイルの結果を出す前に停止します。ASTが一部返ったことをもって解析成功とはしません。正常に読めて0件だった場合と、読めなかった場合を分けるためです。

戻り値は表に整形せず、PathLineColumnNameKindNameを持つオブジェクトにしています。5章のように絞り込むほか、複数ファイルの結果をCSVに保存できます。FullNameというプロパティ名でも入力を受けるので、Get-ChildItemが返すFileInfoをそのまま渡せます。

Get-ChildItem -LiteralPath .\scripts -Filter *.ps1 -File -Recurse |
    Get-ScriptCommand |
    Export-Csv -LiteralPath .\commands.csv -NoTypeInformation -Encoding UTF8 -NoClobber

-NoClobberは既存のCSVへの上書きを防ぐ指定です。途中のファイルで失敗した場合、それ以前の結果は既にCSVへ流れていることがあります。ファイルができたことだけで全件成功とは判断せず、エラーも確認してください。

解析する文法は、ツールを動かしているPowerShellのバージョンに従います。7系で構文解析に成功しても、5.1で実行できるとは限りません。5.1でも日本語のファイルを読む場合は、UTF-8 BOM付きなど、文字コードにも配慮してください。13

サンプルと検証

解析ツール・サンプル・テスト一式(ZIP)には、完成版の関数、解析対象の例、Pesterテストを収録しています。関数本体は掲載コードと同じで、配布版にはヘルプコメントを付けています。ZIPを取得できない場合も、上の完成コードを保存して使えます。

完成版の関数と既存26ケースのPesterテストは前稿から変更していません。改稿時には、本文から抽出した12個のPowerShellコードブロックをWindows PowerShell 5.1とPowerShell 7系で実行し、型名・元のコード・呼び出し位置・絞り込み結果を照合しました。詳しいバージョンと検証範囲は、配布サンプルのREADMEに記載しています。

図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全5件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle

関連記事

参考リンク

  1. Microsoft Learn, Parser.ParseInput Method. 文字列からASTを返し、トークンと構文エラーを出力引数に返すAPIについて。  2

  2. Microsoft Learn, about_Quoting_Rules. 単一引用符のヒアストリングと、展開可能文字列の部分式について。  2

  3. Microsoft PowerShell Team, Using abstract syntax trees (ASTs) with ISE to make scripting more productive. PowerShellから構文木へアクセスし、関数定義などのノードを検索する方法について。  2

  4. Microsoft Learn, NamedBlockAst Class. 名前を明示しないブロックと、文を保持するStatementsについて。 

  5. Microsoft Learn, PipelineAst.PipelineElements Property. パイプラインを構成する要素について。 

  6. Microsoft Learn, IScriptExtent Interface. ソースの範囲、開始位置、行・列が1始まりであることについて。  2

  7. Microsoft Learn, CommandAst.GetCommandName Method. 静的に名前を得られない呼び出しではnullが返ることについて。  2 3

  8. Microsoft Learn, CommandAst.CommandElements Property. 呼び出し名と引数などの構文要素について。 

  9. Microsoft Learn, Ast.FindAll Method. 条件に一致するノードの走査と、入れ子の関数・スクリプトブロックを探す指定について。 

  10. Microsoft Learn, Parser.ParseFile Method. ファイルを解析してAST、トークン、構文エラーを得るAPIについて。 

  11. Microsoft Learn, about_Command_Precedence. 同名コマンド、エイリアス、関数などの実行時の優先順位について。  2

  12. Microsoft Learn, InvokeMemberExpressionAst Constructors. インスタンス・静的メソッド呼び出しを表すノードについて。 

  13. Microsoft Learn, about_Character_Encoding. Windows PowerShellのスクリプト読み取りと、UTF-8 BOMの扱いについて。 

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

よくある質問

この記事のテーマについて、相談時によくある質問をまとめています。

PowerShellのASTとは何ですか?
コードを、代入・コマンド呼び出し・関数定義などの構文ごとのノードで表した抽象構文木です。同じ文字がコメントや文字列に書かれている場合と、呼び出しの名前として書かれている場合を区別して調べられます。
解析対象のps1を実行する必要はありますか?
この記事のツールはParser.ParseFileで読み、対象を起動したりドットソースしたりしません。ただし、安全性を保証するサンドボックスではなく、自分で管理するスクリプトの棚卸しを想定しています。呼び出しが見つからないことも、安全性の証明にはなりません。
変数を使って呼び出すコマンド名も分かりますか?
GetCommandNameで静的に名前を得られない場合はnullになります。この記事のツールはその行を捨てず、NameKindをUnresolvedとして残します。変数の代入を追跡したり、式を実行して名前を推測したりはしません。
Windows PowerShell 5.1でも使えますか?
掲載ツールは5.1と7系を対象にしています。ただし解析に使われる文法は、ツールを動かしているPowerShellのものです。7系で構文解析できたことは5.1との互換性の証明ではありません。日本語を含むファイルを5.1でも読む場合は、UTF-8 BOM付きなど文字コードにも配慮してください。
PSScriptAnalyzerとはどう使い分けますか?
この記事のツールは呼び出しの名前と位置を一覧にするためのものです。既存ルールで品質を検査して警告を管理したい場合はPSScriptAnalyzerを使います。ここで得たASTの読み方は、静的解析の結果や独自ルールの仕組みを理解する入口になります。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る