PowerShellがデフォルトで読み込むようになったPSReadLineモジュールには「補完機能」を強化するショートカットキーや機能が実装されている。PowerShell 7が提供する補完機能は基本的に「Tab」キーを押すことで機能し、表示候補を1つずつ表示してくれる。PSReadLineモジュールでは「Ctrl」+「@」または「Ctrl」+「Space」にさらに機能強化された補完機能が実装されている。PowerShellをインタラクティブシェルとして使うために欠かすことのできない機能だ。

ベーシックな補完機能

まず、PowerShell 7が提供する基本的な補完機能を見てみよう。次のようにコマンドレットのパラメータを入力する段階で「Tab」キーを押してみよう。

パラメータ入力時に「Tab」キーを押す

すると次のスクリーンショットのように、パラメータの1つが補完入力される。ここで続けて「Tab」キーを押すと、次のパラメータに入れ替わる。「Tab」キーを押すことで補完候補が順番に表示されていく。これがPowerShell 7の基本的な補完機能だ。

Tabキーを押すことで順次補完候補が表示される

これまでのショートカットキーがそうであったように、「Tab」キーによる補完にも逆方向がある。「Shift」+「Tab」のように「Shift」キーを付けると補完候補の表示が逆順となる。

「Shift」+「Tab」で逆順に補完候補が表示される

1つ前の補完候補が表示された状態

PowerShellでは「Shift」キーはあるショートカットキーに割り当てられている機能の逆方向というか反対方向の意味として機能させるために使われていることが多い。

拡張された補完機能 「Ctrl」+「Space」と「Ctrl」+「@」

「Tab」キーによる補完はUNIX系の多機能インタラクティブシェルでもよく使われている機能だ。しかし、PowerShellの「Tab」キーによる補完は、候補が1つずつ順番に表示されるだけなので、補完候補が多いと不便だし、補完候補を一覧で確認できないという不満点もある。

PSReadLineモジュールではこの不満点を解消する方法として「Ctrl」+「Space」または「Ctrl」+「@」 に拡張された補完機能が用意されている。先程と同じ状況で「Ctrl」+「Space」または「Ctrl」+「@」 を押してみよう。

「Ctrl」+「Space」または「Ctrl」+「@」を押す

すると次のように補完候補が一覧で表示される。これならどんな補完候補があるかの全体像がよくわかる。補完候補はカーソルキーで移動できるし、「Tab」キーでも移動できる。視覚的に選択するとなるとカーソルキーで対象まで移動するのがわかりやすいだろう。

補完候補はカーソルキーや「Tab」キーで移動できる

「Ctrl」+「Space」または「Ctrl」+「@」で表示された補完候補は絞り込みを行うことができる。例えば上記状態で「D」キーを押してみよう。

試しに候補一覧が表示された状態で「D」キーを押す

すると次のように、Dから始まるパラメータだけに表示される候補が限定される。このようにインタラクティブに動作するため、より効率的に補完候補の絞り込みが可能だ。

「D」から始まるパラメータだけに表示が限定される

UNIX系のインタラクティブシェルでは文脈に合わせて自動的にこうした一覧表示が行われるものもある。PSReadLineがこの機能に割り当てている「Ctrl」+「Space」はほかのアプリケーションでも補完機能のショートカットキーとして使われていることがあるので、覚えるならこのキーは比較的お薦めだ。

補完後方が1つだけのときは「Ctrl」+「Space」も「Tab」キーも同じ

PSReadLineモジュールを使う限り、実は「Tab」キーは使わずに「Ctrl」+「Space」だけに使い方を揃えてしまうという方法もある。たとえば、次の補完例を見てみよう。

「Ctrl」+「Space」で補完開始

補完候補が2つ表示されている

今度は次のように「dir -Literal」まで入力した状態で「Ctrl」+「Space」を押す。

「dir -Literal」まで入力した状態で「Ctrl」+「Space」と押す

「Ctrl」+「Space」または「Ctrl」+「@」は候補を一覧表示する機能だが、対象が1つしかない場合には「Tab」キーを押したときと同じように、その候補を表示するだけで処理が終わる。

補完候補が1つのときはそのまま補完候補が表示される

このため、もう「Tab」キーは押さずに「Ctrl」+「Space」だけ押すようにするというのも手となる。「Ctrl」+「Space」だけで「Tab」キーとしても使えるようなものだからだ。

コマンドレットなど補完候補はほかにも

ここでは補完候補としてパラメータの例を挙げたが、補完は別にパラメータだけではない。次のスクリーンショットはコマンドやコマンドレットの頭文字としてbを入力した後に「Ctrl」+「Space」を押した場合だ。

bと入力してから「Ctrl」+「Space」と打つ

すると、次のようにドライブ名、コマンド名、コマンドレット名などが補完候補一覧に表示されることを確認できる。

コマンドやコマンドレットも補完一覧表示の対象

このように「Ctrl」+「Space」または「Ctrl」+「@」は結構広く汎用的に使える補完機能だ。覚えておいて損のない機能なので、ぜひ活用していただきたい。

ショートカット: 「Ctrl」+「@」問題

説明してきたように、PSReadLineモジュールでは「Ctrl」+「Space」と「Ctrl」+「@」が補完機能に設定されているのだが、この「Ctrl」+「@」には1つ問題がある。

この問題は英語キーボードを使っている場合に生じる。英語キーボードでは「@」は「2」キーを「Shift」キーと同時に押したときに入力される。つまり、「Ctrl」+「@」というのは、英語キーボードでは「Ctrl」+「Shift」+「2」と同じだ。

しかし、「Ctrl」+「Shift」+「2」はWindows Terminalで2つ目のプロファイルで新しいタブを作成するという機能に設定されている。

「Ctrl」+「Shift」+「2」: Windows Terminalですでに使われている

PowerShell 7はWindows Terminalでインタラクティブシェルとして使われることが想定される。つまり、「Ctrl」+「@」がPowerShellに渡ることはなく、入力するたびにWindows Terminalで2つ目のプロファイルで新しいタブが作成されるという処理が起こってしまう。

Windows Terminalの設定で「Ctrl」+「Shift」+「2」を使わないようにするというのが1つの解決策となるが、それは少々コストが高い方法だ。この場合にはPowerShell側で「Ctrl」+「@」を使わずに「Ctrl」+「Space」を使うという方法でよいだろう。後述するが、逆に「Ctrl」+「Space」を別の機能として使いたい場合には「Ctrl」+「@」を使うためにWindows Terminalのほうの設定を変更すればよい。この辺りは、やりたいことに応じて柔軟に選択してもらえればと思う。

ショートカット:「Ctrl」+「Space」問題

「Ctrl」+「@」 の問題については上述の通りだが、実は「Ctrl」+「Space」にも1つ問題がある。

こちらの問題も英語キーボードを使っている場合に表れやすい。英語キーボードには「半角/全角キー」は存在しないので、英語キーボードでは別のキーにIMEのオン/オフの機能を割り当てることになる。最近のWindows 10だと、次のような感じで「Ctrl」+「Space」がIMEのオン/オフ切り替えとして使える設定が提供されている。

最近のWindows 10では「Ctrl」+「Space」にIMEオン/オフ切り替えを割り当て可能

つまり、英語キーボードを使っている場合、日本語入力のオン/オフを切り替えるか、「Ctrl」+「Space」でPowerShell(PSReadLineモジュール)の補完機能を使うのかの2択を迫られることになってしまう。

そして「Ctrl」+「Space」の代わりに「Ctrl」+「@」を使おうとすると、Windows Terminalの「Ctrl」+「Shift」+「2」と被るという事態に陥る。この問題は「英語キーボードで日本語入力を行っているユーザー」が遭遇することになる。全体数としてはそれほど多くないかもしれないが、PowerShellをインタラクティブシェルとして使おうとしているパワーユーザーにはこうした方も多いだろう。

現時点ではこの問題を解決するにはいくつかの方法が残っている。最も簡単なのは古い日本語IMEを有効化して、そちらの設定でIMEオン/オフ機能を別のキーに割り当てる方法だ。

レガシーな日本語IMEを使ってIMEオン/オフを別のキーに割り当てる

いつまでレガシーな日本語IMEが提供されるかわからないのでこの方法は使いたくないのだが、しばらくはこの設定で問題は回避できるはずだ。そのうち新しい日本語IMEで「Ctrl」+「Space」以外にショートカットキーが割り当てられるようになるか、PSReadLineモジュールが別のキーにショートカットを割り当ててくれると助かるところではある。

ショートカットキーがぶつかる問題は遅かれ早かれかならず発生する問題だし、操作の効率化を図っていく上で避けては通れない。丁寧に整理していけば、どこの設定を変更するのが最も妥当か見えていくるはずだ。PowerShellで「Ctrl」+「Shift」がもたらす恩恵は大きい。ぜひともこのキーを活かす方向で設定を調整していっていただきたい。

付録: PSReadLineショートカットキー一覧

キー 機能
Enter 入力行を実行しカーソルを次の行へ移動。入力が閉じていない場合には入力行を実行せずにカーソルを次の行へ移動
Shift-Enter 入力行を実行せずにカーソルを次の行へ移動
Ctrl-Enter 入力行を実行せずに前の行に空行を挿入
Ctrl-Shift-Enter 入力行を実行せずに次の行に空行を挿入
Backspace カーソルよりひとつ前の文字を削除
Ctrl-h カーソルよりひとつ前の文字を削除
Delete カーソル下の文字を削除
Ctrl-Home カーソルから行頭までを削除
Ctrl-End カーソルから行末までを削除
Ctrl-Backspace カーソルから単語の先頭までを削除
Ctrl-w カーソルから単語の先頭までを削除
Ctrl-Delete カーソルから単語の末尾までを削除
Alt-d カーソルから単語の末尾までを削除
Ctrl-v システムクリップボードのテキストを貼り付け
Shift-Insert システムクリップボードのテキストを貼り付け
Ctrl-Shift-c 選択したテキストをシステムクリップボードへコピー
Ctrl-c 選択したテキストをシステムクリップボードへコピー。テキストが選択されていなかった場合には行の編集をキャンセル
Ctrl-x 選択したテキストを削除
Ctrl-z アンドゥ
Ctrl-y リドゥ
ESC すべてアンドゥ
Alt-. 直前実行の最後の引数を貼り付け
キー 機能
カーソルを左へ移動
カーソルを右へ移動
Ctrl-← カーソルを左の単語の先頭へ移動
Ctrl-→ カーソルを右の単語の先頭へ移動
Home カーソルを行頭へ移動
End カーソルを行末へ移動
Ctrl-] カーソルを対応するカッコへ移動
キー 機能
コマンド履歴のひとつ前のコマンドへ入れ替え
コマンド履歴のひとつ後のコマンドへ入れ替え
F8 コマンド履歴検索(古い履歴へ向かって)
Shift-F8 コマンド履歴検索(新しい履歴へ向かって)
Ctrl-r コマンド履歴検索(古い履歴へ向かってインタラクティブに検索)
Ctrl-s コマンド履歴検索(新しい履歴へ向かってインタラクティブに検索)
Alt-F7 コマンド履歴をクリア
キー 機能
Ctrl-@ 補完。補完対象が複数ある場合にはメニュー形式で候補を表示
Ctrl-Space 補完。補完対象が複数ある場合にはメニュー形式で候補を表示
Tab 補完。次の補完候補を表示
Shift-Tab 補完。前の補完候補を表示
キー 機能
Ctrl-a 行全部を選択し、カーソルを行末へ移動
Shift-← カーソルの選択位置をひとつ左へ移動
Shift-→ カーソルの選択位置をひとつ右へ移動
Shift-Home カーソルから行頭までを選択
Shift-End カーソルから行末までを選択
Shift-Ctrl-← カーソルからひとつ前の単語の先頭までを選択
Shift-Ctrl-→ カーソルからひとつ後の単語の先頭までを選択
キー 機能
F3 指定した文字へカーソルを後ろへ移動
Shift-F3 指定した文字へカーソルを前へ移動
キー 機能
Ctrl-l スクリーンのクリアと再描画およびカレント行をスクリーン上部へ移動
Alt— 数値を0回繰り返し入力
Alt-0 数値を0回繰り返し入力
Alt-1 数値を1回繰り返し入力。または追加指定した分繰り返し入力
Alt-2 数値を2回繰り返し入力。または追加指定した分繰り返し入力
Alt-3 数値を3回繰り返し入力。または追加指定した分繰り返し入力
Alt-4 数値を4回繰り返し入力。または追加指定した分繰り返し入力
Alt-5 数値を5回繰り返し入力。または追加指定した分繰り返し入力
Alt-6 数値を6回繰り返し入力。または追加指定した分繰り返し入力
Alt-7 数値を7回繰り返し入力。または追加指定した分繰り返し入力
Alt-8 数値を8回繰り返し入力。または追加指定した分繰り返し入力
Alt-9 数値を9回繰り返し入力。または追加指定した分繰り返し入力
PageDown スクリーンを1画面分下へスクロール
PageUp スクリーンを1画面分上へスクロール
Ctrl-PageDown スクリーンを1行分下へスクロール
Ctrl-PageUp スクリーンを1行分上へスクロール
Alt-? キーに割り当てられている機能を表示(指定したキー)
Ctrl-Alt-? キーに割り当てられている機能を表示(すべて)

付録: $PROFILE

本連載時点での$PROFILEを次に掲載しておく。

$PROFILE

#========================================================================
# Set encoding to UTF-8
#========================================================================
# [System.Console]::OutputEncoding is set to local encoding, so character
# corruption occurs when piped from WSL to WSL. Therefore, set
# [System.Console]::OutputEncoding and $OutputEncoding to UTF-8 to avoid
# the problem.
$OutputEncoding = [System.Console]::OutputEncoding =
    [System.Text.UTF8Encoding]::new()

#========================================================================
# Linux commands integration mode
#========================================================================
function linuxcmds {

    #----------------------------------------------------------------
    # Definition of Linux commands used via wsl
    #----------------------------------------------------------------
    # Linux pagers
    $_linux_pagers = @("less", "lv")

    # Linux PATH and commands
    Write-Host "checking linux commands:"
    $_linux_path = (wsl echo '$PATH').Split(":") -NotMatch "/mnt"
    $_linux_command_paths = (
        wsl ls -d ($_linux_path[($_linux_path.Length - 1)..0] -replace
            "$","/*")
    ) 2> $null

    # Generate Linux commands functions
    ForEach($n in $_linux_command_paths) {
        $_n = (Split-Path -Leaf $n)
        $_linux_functions += "
            function global:$_n {
                if (`$Input.Length) {
                    `$Input.Reset()
                    `$Input | wsl $n ([String]`$(_path_to_linux
                        `$Args)).Split(' ')
                }
                else {
                    wsl $n ([String]`$(_path_to_linux `$Args)).Split(' ')
                }
            }
            Write-Host -NoNewline .
        "
        Write-Host -NoNewline '_'
    }

    # Generate Linux pagers functions
    ForEach($_n in $_linux_pagers) {
        $_linux_functions += "
            function global:$_n {
                if (`$Input.Length) {
                    `$Input.Reset();

                    # Prepare temporary file path
                    `$_temp = New-TemporaryFile

                    # Write data from pipeline to the temporary file
                    `$Input | Out-File `$_temp

                    # Do $_n
                    wsl $_n `$(_path_to_linux `$Args).Split(' ') ``
                        `$(_path_to_linux `$_temp.ToString()).Split(' ')

                    # Delete unnecessary temporary file and variable
                    Remove-Item `$_temp
                    Remove-Variable _temp
                }
                else {
                    wsl $_n `$(_path_to_linux `$Args).Split(' ')
                }
            }
            Write-Host -NoNewline .
        "
        Write-Host -NoNewline '_'
    }

    # Function that converts Windows paths to Linux paths
    $_linux_functions += @'
        function global:_path_to_linux {
            $linuxpath = @()

            # Convert arguments to Linux path style
            ForEach($winpath in $Args) {
                if ($winpath -eq $null) {
                    Break
                }

                # Change drive path to mount path
                if ($winpath -match '^[A-Z]:') {
                    $drive = $winpath.Substring(0,1).ToLower()
                    $linuxpath += "/mnt/" + $drive +
                $winpath.Substring(2).Replace('\','/')
                }
                # Option is not converted
                elseif ($winpath -match '^[-+]') {
                    $linuxpath += $winpath
                }
                # Other argument is converted
                else {
                    $linuxpath += ([String]$winpath).Replace('\','/')
                }
            }

            $linuxpath
        }
        Write-Host .
'@
    Write-Host -NoNewline '_'

    # Prepare temporary file path with extension .ps1
    $_temp = New-TemporaryFile
    $_temp_ps1 = $_temp.FullName + ".ps1"
    Remove-Item $_temp

    # Write function definition to temporary .ps1 file and parse
    $_linux_functions | Out-File $_temp_ps1
    Write-Host
    Write-Host "functionizing linux commands:"
    . $_temp_ps1
    Remove-Item $_temp_ps1

    # Delete unnecessary variables
    Remove-Variable n
    Remove-Variable _n
    Remove-Variable _temp
    Remove-Variable _temp_ps1
    Remove-Variable _linux_pagers
    Remove-Variable _linux_path
    Remove-Variable _linux_command_paths
    Remove-Variable _linux_functions

    #----------------------------------------------------------------
    # Individual Linux command function definitions
    #----------------------------------------------------------------
    # grep
    function global:grep {
        $pattern_exists = $False
        $path_exists = $False
        $skip = $False
        $i = 0

        ForEach($a in $Args) {
            if ($skip) {
                $skip = $False
                $i++
                continue
            }

            # Options without argumetn
            if ($a -cmatch '^-[abcdDEFGHhIiJLlmnOopqRSsUVvwxZ]') {
            }
            # Options with argument
            elseif ($a -cmatch '^-[ABC]') {
                $skip = $True
            }
            # Pattern file specification option
            elseif ($a -ceq '-f') {
                $skip = $True
                $pattern_exists = $True
                $Args[$i+1] = _path_to_linux $Args[$i+1]
            }
            # Pattern specification option
            elseif ($a -ceq '-e') {
                $skip = $True
                $pattern_exists = $True
            }
            # Pattern or file path
            elseif ($a -cnotmatch '^-') {
                if ($pattern_exists) {
                    $path_exists = $True
                }
                else {
                    $pattern_exists = $True
                }
            }

            $i++
        }

        # Change file path
        if ($path_exists) {
            $Args[-1] = _path_to_linux $Args[-1]
        }

        $Input | wsl grep $Args
    }

    # ls
    Get-Alias ls *> $null && Remove-Item alias:ls
    function global:ls { wsl ls --color=auto $(_path_to_linux
        $Args).Split(' ') }
    function global:ll { ls -l $(_path_to_linux $Args).Split(' ') }
    function global:la { ls -a $(_path_to_linux $Args).Split(' ') }
}

#========================================================================
# Alias definition
#========================================================================
Set-Alias -Name open -Value explorer
Set-Alias -Name edge -Value `
    "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"
Set-Alias -Name chrome -Value `
    "C:\Program Files (x86)\Google\Chrome\Application\chrome.exe"
Get-Alias man *> $null && Remove-Item alias:man

#========================================================================
# cd shortcuts
#========================================================================
function cd1 { cd (dir -Directory)[-1] }
function cd2 { cd (dir -Directory)[-2] }
function cd3 { cd (dir -Directory)[-3] }
function cd4 { cd (dir -Directory)[-4] }
function cd5 { cd (dir -Directory)[-5] }
function cd6 { cd (dir -Directory)[-6] }
function cd7 { cd (dir -Directory)[-7] }
function cd8 { cd (dir -Directory)[-8] }
function cd9 { cd (dir -Directory)[-9] }
function cd10 { cd (dir -Directory)[-10] }
function cd11 { cd (dir -Directory)[-11] }
function cd12 { cd (dir -Directory)[-12] }
function cd13 { cd (dir -Directory)[-13] }
function cd14 { cd (dir -Directory)[-14] }
function cd15 { cd (dir -Directory)[-15] }
function cd16 { cd (dir -Directory)[-16] }
function cd17 { cd (dir -Directory)[-17] }
function cd18 { cd (dir -Directory)[-18] }
function cd19 { cd (dir -Directory)[-19] }
function cd20 { cd (dir -Directory)[-20] }
#function cd1 { cd (dir | Select-String -Pattern '[0-9]{8}$')[-1] }
#function cd2 { cd (dir | Select-String -Pattern '[0-9]{8}$')[-2] }
#function cd3 { cd (dir | Select-String -Pattern '[0-9]{8}$')[-3] }
#function cd4 { cd (dir | Select-String -Pattern '[0-9]{8}$')[-4] }
#function cd5 { cd (dir | Select-String -Pattern '[0-9]{8}$')[-5] }
#function cd6 { cd (dir | Select-String -Pattern '[0-9]{8}$')[-6] }
#function cd7 { cd (dir | Select-String -Pattern '[0-9]{8}$')[-7] }
#function cd8 { cd (dir | Select-String -Pattern '[0-9]{8}$')[-8] }
#function cd9 { cd (dir | Select-String -Pattern '[0-9]{8}$')[-9] }
#function cd10 { cd (dir | Select-String -Pattern '[0-9]{8}$')[-10] }
#function cd11 { cd (dir | Select-String -Pattern '[0-9]{8}$')[-11] }
#function cd12 { cd (dir | Select-String -Pattern '[0-9]{8}$')[-12] }
#function cd13 { cd (dir | Select-String -Pattern '[0-9]{8}$')[-13] }
#function cd14 { cd (dir | Select-String -Pattern '[0-9]{8}$')[-14] }
#function cd15 { cd (dir | Select-String -Pattern '[0-9]{8}$')[-15] }
#function cd16 { cd (dir | Select-String -Pattern '[0-9]{8}$')[-16] }
#function cd17 { cd (dir | Select-String -Pattern '[0-9]{8}$')[-17] }
#function cd18 { cd (dir | Select-String -Pattern '[0-9]{8}$')[-18] }
#function cd19 { cd (dir | Select-String -Pattern '[0-9]{8}$')[-19] }
#function cd20 { cd (dir | Select-String -Pattern '[0-9]{8}$')[-20] }
function doc { cd ~/Documents/ }
function dwm { cd ~/Downloads/ }
function drv { cd ~/OneDrive/ }
function pic { cd ~/Pictures/ }
function vid { cd ~/Videos/ }

#========================================================================
# utilities shortcuts
#========================================================================
function g_pull { git pull }
function g_push { git commit -m 'updated'; git push origin master }
function g_add { git add }

function getpass { openssl rand -base64 20 }