Register-ArgumentCompleter で 入力補完機能を自作しよう!

これは、PowerShell Advent Calendar 2015 9日目の記事です。

atnd.org

目次

はじめに

以前、TabExpansion++ というモジュールを使うと、自前の入力補完機能を簡単に作ることが出来る、という内容の記事を書きました。

pierre3.hatenablog.com

この入力補完のカスタマイズ機能ですが、PowerShell v5.0 から標準で使えるようになり、より手軽に使用できるようになっていました。
それに伴い、Register-ArgumentCompleterというコマンドレットが追加されていて、このコマンドレットを実行するだけで、自前の入力補完機能が登録できるようになったようです。

今回は、このRegister-ArgumentCompleterコマンドレットを使って、自前の入力補完機能を実装してみたいと思います。

Register-ArgumentCompleter コマンドレット

まずは、Register-ArgumentCompleter コマンドレットに渡すパラメータを確認してみましょう。

Register-ArgumentCompleter `
    -CommandName (ターゲットコマンド名) `
    -ParameterName (ターゲットパラメータ名) `
    -ScriptBlock (入力候補一覧を作成するスクリプトブロック)
  • CommandName パラメータに、補完機能のを登録するコマンドの名前を指定します。
  • ParameterName パラメータに、補完対象となるパラメータの名前を指定します。
  • ScriptBlock パラメータに、独自の入力候補一覧を作成する処理を記述したスクリプトブロックを指定します。(詳細は後述します)

入力候補の一覧を生成するスクリプトブロック

ScriptBlock パラメータには、System.Management.Automation.CompletionResult オブジェクトをコレクションで返すスクリプトブロックを指定します。
また、スクリプトブロックには、5つのパラメータが渡ってきて、入力途中の文字列などが取得できるようになっています。

スクリプトブロックの概要をざっくりと書くと以下の様になります。

$createCompletionResults = {
    # 以下の5つのパラメータを受け取る。入力中の文字列は $wordToComplete に入ってくる。
    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter)
    
    $items = (入力候補のソースを取得)    

    # 入力候補の数だけ値を出力する
    foreach($item in $items)
    {
        $completionText = (決定時にパラメータ値として挿入される文字列);
        $listItemText = (入力候補の一覧に表示される文字列);
        $resultType = (入力候補のタイプ。候補の左側に表示されるアイコンを指定できる);
        $toolTip = (ツールチップに表示される文字列);
        
        # CompletionResult オブジェクトとして出力する
        [System.Management.Automation.CompletionResult]::new(
            $completionText,
            $listItemText,
            $resultType,
            $toolTip);   
    }
}

それでは、これを踏まえて実際に入力補完機能を作ってみましょう。

Invoke-Item コマンドレットの入力候補に「最近使った項目」の一覧を出す

Invoke-Item コマンドレットのPathパラメータ入力時に「最近使った項目」の一覧を出して、最近使ったファイルに素早くアクセスできるようにしたいと思います。

  • Invoke-Itemコマンドレットは、指定したファイルを実行したり、フォルダを開いたりするのに使用するコマンドレットです。
    エクスプローラ上で任意のファイルやフォルダをダブルクリックした時と同様の動作をします。

  • 「最近使った項目」は、%AppData%\Microsoft\Windows\Recent フォルダにショートカットとして保存されます。スタートメニューやタスクバーアイコンを右クリックすると表示される「最近使ったもの」一覧で利用されます。

f:id:pierre3:20151203225018p:plain

実装

実装したコードは、以下の通りです。
細かな部分はコード内のコメントと、後述の補足説明をご確認ください。

「最近使った項目」フォルダからショートカットファイルの一覧を取得する

「最近使った項目」(Recent)フォルダは、[System.Environment]::GetFolderPath() メソッドで取得できます。
取得したフォルダ以下のファイルは Get-ChildItem コマンドで取得しています。フォルダ名の下に\*.lnk を付けることで、ショートカットファイル(.lnk)のみを取得するようにしています。
取得したファイルの一覧は、更新日時の新しい順にソート後、先頭から表示したい件数分(ここでは50件)を取得しています。

# Recentフォルダから、最新50件のショートカット(*.lnk)ファイルを取得
$recentFolder = [System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::Recent);
$items = Get-ChildItem "$($recentFolder)\*.lnk" |
    Sort-Object -Property LastWriteTime -Descending | 
    Select-Object -First 50;

Pathを直接入力する場合にも対応する

「最近使った項目」の一覧から選択だけでなく、Pathを直接入力したい場合にも対応するため、スクリプトブロックの冒頭で以下のコードを入れています。

# "\" が含まれる場合は、通常のPath入力とみなし、独自の入力補完は行わない
# 例えば"C:\" と入力した場合など
if(([string]$wordToComplete).Contains("\") )
{ 
    return; 
}

こうすることで、".\" や、"C:\" などと入力した場合は 通常のPathを補完するダイアログが表示されるようになります。
スクリプトブロックが、なにも返さなかった場合は、通常の補完機能が動作することを利用しています。)

入力済みの文字列がマッチする項目だけを一覧に含める

パラメータに入力途中の文字がある状態で、補完機能が実行された場合、「補完一覧に表示する文字列」に「入力済みの文字列」がマッチする項目のみを一覧に含めるようにしています。

# 一覧に表示する文字列
$listItemText = Split-Path $shortcut.TargetPath -Leaf;
# 入力中の文字列$wordToCompleteがマッチしない項目はスキップ
if($listItemText -notmatch $wordToComplete)
{
    continue;
}

ショートカットの情報はWSHのCreateShortCut()で取得

どうやらショートカットファイルを操作するPowerShellのコマンドはないようなので ショートカットの参照元を取得するために、WSHの機能を使用しています。

$wsh = new-object -comobject WScript.Shell
$shortcut = $Wsh.CreateShortcut("path\to\shortcut");
# 参照元のパス
$shortcut.TargetPath;

実行結果

Invoke-Item の第1引数(-Path)で [Ctrl]+[Space](「最近使った項目」の一覧を表示)

f:id:pierre3:20151207220053p:plain

"ab" を入力後 [Ctrl]+ [Space] (「最近使った項目」で名前に "ab" を含むファイルのみ表示)

f:id:pierre3:20151207220232p:plain

"c:\" を入力 (c ドライブのアイテム一覧を表示)

f:id:pierre3:20151207220256p:plain

ちゃんと、期待通りの動きになっているようです。

まとめ

Register-ArgumentCompleterコマンドレットを使う事で、自前の入力補完機能が簡単に実装できることが分かりました。
この機能が必要となるケースはそれほど多くはないかもしれませんが、使い方によっては非常に有用な機能となり得るのではないでしょうか。

おまけ

Invoke-Item でファイルを開いても、「最近使った項目」が更新されない問題が発覚しました。
どうやら、エクスプローラや標準のファイル選択ダイアログを使わないと「最近使った項目」は更新されないようです。

これはあまり嬉しくないので、Invoke-Item コマンドを実行後に、「最近使った項目」を更新する処理を追加したコマンドレットを作って、これを使う様にしました。

function Invoke-ItemEx
{
    [CmdletBinding()]
    [Alias("iix")]
    Param
    (
        [Parameter(Mandatory=$true, Position=0)]
        [string]
        $Path
    )

    if(-not(Test-Path $Path))
    {
        Write-Error -Exception ([System.ArgumentException]::new(
            "存在するファイル名またはフォルダ名を指定してください","Path"));
        return;
    }

    Invoke-Item -Path  $Path;
    
    # ファイルのみ    
    if(Test-Path $Path -PathType Leaf)
    {
        $Shell = New-Object -ComObject Shell.Application;
        $Shell.AddToRecent($Path);
    }
}

Register-ArgumentCompleter -CommandName Invoke-ItemEx -ParameterName Path `
    -ScriptBlock $recentItemsCompletion
  • 「最近使った項目」への登録は Shell.Application の AddToRecent()を使います。
  • AddToRecent()に、アクセスしたファイルのPathを渡して実行すると、Recentフォルダに以下のショートカットが登録されます。
    • 引数に渡したファイルのショートカット
    • そのファイルを格納しているフォルダのショートカット
  • AddToRecent()にフォルダのPathを渡すと、その1階層上のフォルダのショートカットが登録されてしまいます。
    • これは期待する動作ではないので、AddToRecent()にはファイルのPathのみを渡すようにしました。