前回までに、Linuxのコマンドをごっそり関数化してPowerShellから利用できるようにした。Linuxの環境変数PATHに含まれているコマンドを自動的に関数化したので、ほぼ全てのコマンドに対応した点がポイントだ。該当部分のスクリプトは次のようになっている。

$_linux_path = (wsl echo '$PATH').Split(":") -NotMatch "/mnt"
$_linux_command_names = wsl ls $_linux_path

# Generate Linux command functions
ForEach($n in $_linux_command_names) {
    if ($n -ne "") {
        $_linux_functions += "
            function $n {
                if (`$Input.Length) {
                    `$Input.Reset()
                    `$Input | wsl $n `$(_path_to_linux `$Args)
                }
                else {
                    wsl $n `$(_path_to_linux `$Args)
                }
            }"
    }
}

感の鋭い方ならすでにお気づきかと思うが、このスクリプトは環境変数PATHの優先順位を考慮していない。上記の場合、環境変数PATHは「/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin」のような値が設定されているので、まず/usr/local/sbin以下を検索し、次に/usr/local/bin以下を検索し……といったようにコマンド検索が行われていく。

上記のスクリプトでは順位を考慮せず、コマンド名だけで処理をしているのだが、コマンドの実行時にフルパスを指定していないので結局Linuxの環境変数PATHの結果が反映されたコマンドが実行されるという”結果オーライ”の内容になっているのだ。今回は、この部分を厳密に実行するように書き換えていこう。PowerShellスクリプトのサンプルとして興味深いので、ぜひその流れを追ってみていただきたい。

アイデアはシンプル

厳密に処理するための考え方はシンプルだ。

  1. コマンド一覧を環境変数PATHの優先度を加味したものに変更する
  2. コマンド一覧はフルパスで用意する
  3. 関数はフルパスでコマンドを実行する


このスクリプトは全てのLinuxコマンドに対して個別に関数を作成するという作戦を取っている。関数は後に定義されたものが先に定義されたものを上書きする。つまり、優先順位の高いコマンドほど後ろ側で関数定義すればよいわけだ。今回は、これを実装してみよう。

PATHの順序を逆順にする

Linuxの環境変数PATHは優先順位の高いパスほど前に記載する決まりになっている。本連載で作成した設定スクリプトでは環境変数PATHを「Split(“:”)」で分解して配列に入れる処理しかしていないので、配列の最初のほうに優先順位の高いパスが入っていることになる。

しかし、関数定義で優先順位の高いほうを後ろで定義したい。それには、配列の中身を逆転させて、優先順位の高いパスほど最後に持っていければよい。

やり方はいくつかあるが、ここでは単純にインデックスの指定を逆転させる方法で済ませよう。プログラミングの経験がある方ならわかると思うが、値の中身を書き換えるとほかの部分で問題が出ることがある。問題が出ないようにするために、ほかの部分のソースコードも読まないといけないので面倒だ。かといって、別の変数に割り当てると変数が増えて、それはそれで煩雑になる。変数を増やさず、変数も書き換えずに目的が達成できると、プログラムを書く側としては安心できる。

配列のインデックス指定で中身を逆にする

PowerShellの場合、配列のインデックスを逆に指定すれば配列の中身を逆転させることができる。インデックスを「($_linux_path.length-1)..0」のように指定しているところがポイントだ。これなら配列の中身は書き変わらないし、新しい変数も作らないで済む。

コマンド名をフルパスで得る

コマンドをファイル名だけではなくフルパスで得たい。これもいくつかの方法があるが、シンプルにまとめるならlsコマンドを「ls -d /path/to/*」のように使う方法がよいだろう。以下に示すのは、「ls -d /sbin/*」を実行した結果だが、コマンドをフルパスで取得できていることがわかる。

コマンド名をフルパスで得る方法 - lsコマンドを使う場合

先ほど環境変数PATHを配列に入れたものを逆順にする方法を紹介したが、あのパスの最後に「/*」を加えてlsコマンドに流し込めば、「ls -d *」によるフルパス一覧を得られる。配列には個別にディレクトリパスの文字列が収められているわけだが、これをまとめて処理したいところだ。

PowerShellでは、「-replace」を使うとこれを実現することができる。「-replace」は正規表現が使えるので「末尾を/*に置換」という命令を指定すれば、配列の中身を個々に処理することができる。次に実行例を示す。

配列に入った文字列の末尾に「/*」を付ける例

「$_linux_path[($_linux_path.length-1)..0]」が逆順になったコマンドパス一覧であり、これに「-replace “$”,”/*”」の指定で末尾を「/*」に変換だ。「$_linux_path[($_linux_path.length-1)..0] -replace “$”,”/*”」で変換完了である。配列に対して一発で処理が完了するのでスクリプトがシンプルになる。

エラーメッセージを捨てる

ここまで準備ができたので、実際のスクリプトに落とし込んでいってみよう。実際に動かしてみると、実は次のようなエラーが発生する。

エラーが出ることを確認

これはLinuxの環境変数PATHに含まれているディレクトリパスが実際には存在してないとか、存在していても1つもコマンドが存在していないときに発生するエラーだ。グロブ展開の対象とならないので、「*」が「*」のまま残ってしまい「/usr/local/games/*というファイルは存在しない」というエラーになってしまうのだ。

とりあえずこのまま処理を進めてみよう。まず、実行結果を次のように変数に代入してみる。

実行結果を変数に代入

変数にエラーメッセージは入っておらず、コマンドを実行したときに標準エラー出力に出ていることがわかる。

PowerShellを起動するたびにこのエラーが出ていては面倒だ。次のように処理を書き換えて、エラーメッセージだけを「$null」へ流し込んで消すようにする。

エラーメッセージだけを消す

ちなみに、「2> $null」でエラーメッセージだけ捨てるというのはよくやる処理なので覚えておくとよいだろう。

改行してコンパクトに

先ほどの処理で欲しいフルパスのコマンド一覧は得られるのだが、そのためのコードが長くなってしまった。最近はスクリーンのサイズが大きいので、以前ほど厳密に行幅の制限が指摘されることはなくなっているが、1行が長くなりすぎると後から読んだときにわかりにくくなる。ある程度は折り畳み、コンパクトなソースコードにしておきたい。どこで折り畳める(改行できる)のか知っておくことは見通しのよいスクリプトを書く上で非常に重要だ。

まず、PowerShellでは「(」の後で改行することができる。次のような感じだ。

「(」の後で改行できる

今回のケースだとこれでもまだちょっと長い。「(」で改行できるということは、「)」の前でも改行できる。2カ所改行すると、次のようになる。

1行を3行に書き換えたもの

今回の書き換えはこれまでのスクリプトに対して何の変化も与えていない。新しく「$_linux_command_paths」という変数を追加しただけだ。こうした要領で変更をしていくと、既存の設定には影響を与えずに、徐々に書き換えていくことができる。

今回の書き換え

今回書き換えた部分を次にまとめておく。次回は、ほかの部分にも手を加える予定だ。

$_linux_path = (wsl echo '$PATH').Split(":") -NotMatch "/mnt"
$_linux_command_names = wsl ls $_linux_path
$_linux_command_paths = (
    wsl ls -d ($_linux_path[($_linux_path.length-1)..0] -replace "$","/*")
) 2> $null

# Generate Linux command functions
ForEach($n in $_linux_command_names) {
    if ($n -ne "") {
        $_linux_functions += "
            function $n {
                if (`$Input.Length) {
                    `$Input.Reset()
                    `$Input | wsl $n `$(_path_to_linux `$Args)
                }
                else {
                    wsl $n `$(_path_to_linux `$Args)
                }
            }"
    }
}

実行してみると、次のように新しく作成した変数に目的とするデータが収められていることを確認できる。

フルパスのコマンド一覧

処理としては1行追加しただけだが、今回のスクリプトには次のテクニックが盛り込まれており、参考になるはずだ。

  • 配列の中身を逆順で使う方法
  • 配列の中身に対して個々に文字列置換を実施する方法
  • 文字列置換を正規表現で指定する方法
  • エラーメッセージを捨てる方法
  • 実行結果を変数などに代入せずにそのまま利用する方法


PowerShellの特徴の1つは、シェルスクリプトでありながらもプログラミング言語として高い抽象化が行われている点にある。こうした高い抽象性を活用しながらコンパクトに書いていけるのが、PowerShellスクリプトの良いところだ。

付録: $PROFILE

本連載で利用している現時点の$PROFILEを次に掲載しておく。参考にしてもらえれば幸いだ。

#========================================================================
# Definition of Linux commands used via wsl
#========================================================================
$_linux_path = (wsl echo '$PATH').Split(":") -NotMatch "/mnt"
$_linux_command_names = wsl ls $_linux_path
$_linux_command_paths = (
    wsl ls -d ($_linux_path[($_linux_path.length-1)..0] -replace "$","/*")
) 2> $null

# Generate Linux command functions
ForEach($n in $_linux_command_names) {
    if ($n -ne "") {
        $_linux_functions += "
            function $n {
                if (`$Input.Length) {
            `$Input.Reset()
                    `$Input | wsl $n `$(_path_to_linux `$Args)
        }
        else {
                    wsl $n `$(_path_to_linux `$Args)
        }
            }"
    }
}
$_linux_functions += @'
    function _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
    }
'@

# 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
. $_temp_ps1
Remove-Item $_temp_ps1

# Delete unnecessary variables
Remove-Variable _temp
Remove-Variable _temp_ps1
#Remove-Variable _linux_path
#Remove-Variable _linux_command_names
#Remove-Variable _linux_command_paths
#Remove-Variable _linux_functions

#========================================================================
# Individual Linux command function definitions
#========================================================================
# grep
function 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 ls { wsl ls --color=auto $Args }
function ll { ls -l }
function la { ls -a }

#========================================================================
# 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"