前回、Linuxのlessコマンドを取り上げ、コマンド単体で使う場合/パイプラインに接続して使う場合のどちらでもきちんと動作するように設定した。パイプラインを流れてくるデータをいったんファイルに書き込むことで、パイプが使われた場合もファイルを指定してLinuxのlessコマンドを実行するように変更したのだ。これで、パイプラインを使った場合に発生するいくつかの問題を回避できるようになった。

しかし、しばらく使ってみると、less以外のコマンドでほかの不具合があることがわかった。引数が1つしかない場合は問題ないのだが、2つ以上の引数を取るケースでは期待する動作をしないのだ。例えば、Ubuntuなどの管理に欠かせないaptコマンドは2つ以上の引数を伴うことがあるが、その場合、エラーが発生して動作しない。しかし、同じことをwslコマンドを使って実行すると、正しく動作するのである。

aptコマンドで引数が2つ以上になるとエラーが発生して動作しないが、同じことをwslコマンドで実行すると動作する

つまり、以前$PROFILEに追加したLinuxコマンドを全部関数化する処理のどこかで不具合が発生していることになる。

該当する部分は次の処理だ。

ForEach($n in $_linux_command_paths) {
    $_n = (Split-Path -Leaf $n)
    $_linux_functions += "
        function $_n {
            if (`$Input.Length) {
                `$Input.Reset()
                `$Input | wsl $n `$(_path_to_linux `$Args)
            }
            else {
                wsl $n `$(_path_to_linux `$Args)
            }
        }"
}

この処理ではwslコマンドの記述が2カ所ある。次の部分だ。

◆wslを実行している部分 - コマンド単体置き換え

wsl $n `$(_path_to_linux `$Args)

◆wslを実行している部分 - パイプラインに接続しているコマンド置き換え

`$Input | wsl $n `$(_path_to_linux `$Args)

この部分は「関数化コードをいったんファイルに書き出してから読み込む」という目的のために記述されているので、エスケープコードが入っており、若干わかりづらいかもしれない。エスケープ部分を除くと、次のように記述していることになる。

◆wslを実行している部分 - コマンド単体置き換え

wsl $n $(_path_to_linux $Args)

◆wslを実行している部分 - パイプラインに接続しているコマンド置き換え

$Input | wsl $n $(_path_to_linux $Args)

冒頭に示した動作例と比較すると、「$Args」を「_path_to_linux()」で処理した結果に置き換えている部分に違いがある。この部分は「$()」による部分式演算子を使っているのだが、この処理が直接wslコマンドを使ったケースとは違うのだ。

マイクロソフトのドキュメントで部分式演算子($(): Subexpression operator)の説明「about_Operators - PowerShell | Microsoft Docs」をよく読んでみると、どうもこの演算子の結果はここでは単一の文字列として返ってきているようだ。つまり、複数の引数が指定されていたとしても、部分式演算子を経由した段階で単一の文字列になってしまっているのである。

about_Operators - PowerShell | Microsoft Docs

結果として、冒頭の実行例では「apt list less」という指定が「aptコマンド」と「list」「less」という2つの引数ではなく、「apt list less」という1つのコマンドだと見なされていることになる。これでは期待したように動作しないのももっともだ。

同じ問題で動作していない設定がほかにもあり

同じような記述をしている箇所はほかにもある。例えば、「ls」や「ll」などは次のような設定をしているため、こちらも複数の引数が単一の文字列だと見なされていることになる。

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

実行してみると、次のように引数が2つ以上になるとエラーが発生することを確認できる。

引数2つ以上でエラーを確認

前回作成したless関数にも同じ記述があるので、同じ問題が起きる。

#less
function less {
    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 less
        wsl less $(_path_to_linux $_temp.ToString())

        # Delete unnecessary temporary file and variable
        Remove-Item $_temp
        Remove-Variable _temp
    }
    else {
        wsl less $(_path_to_linux $Args)
    }
}

しかも設定をよく見直してみると、パイプラインが接続された場合の動作では、lessコマンドに指定された引数がそもそも無視されていることもわかった。これは今回とは別のミスだ。前回の書き換えの際、引数の処理を加えるのを忘れている。

試しに実行してみると、次のようにエラーが発生する。

引数2つ以上の場合やパイプラインを使った際のバグを確認

問題部分は明らかになったので、ここを修正していこう。

文字列を配列に変換してから処理することで解決

複数の引数が単一の文字列として扱われることで問題が発生しているのだから、次のように文字列になった後で「.Split(’ ‘)」を使い、再度配列へ分解してみる。

「.Split(’ ‘)」で配列に分解して問題解決

すると、期待通りに動作するようになった。今回は、この方法で修正していこう。

動作確認

該当部分を「.Split(’ ‘)」で変換するように書き換える。それぞれ次のように変更した。

◆Linuxコマンドを関数化する部分(修正後)

ForEach($n in $_linux_command_paths) {
    $_n = (Split-Path -Leaf $n)
    $_linux_functions += "
        function $_n {
            if (`$Input.Length) {
                `$Input.Reset()
                `$Input | wsl $n `$(_path_to_linux `$Args).Split(' ')
            }
            else {
                wsl $n `$(_path_to_linux `$Args).Split(' ')
            }
        }"
}

◆ls関数やll関数(修正後)

function ls { wsl ls --color=auto $(_path_to_linux $Args).Split(' ') }
function ll { ls -l $(_path_to_linux $Args).Split(' ') }
function la { ls -a $(_path_to_linux $Args).Split(' ') }

◆less関数(修正後)

#less
function less {
    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 less
        wsl less $(_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 less $(_path_to_linux $Args).Split(' ')
    }
}

less関数については、パイプラインで処理している部分にそもそも指定されていなかった引数を指定するという処理も追加した。ここでも「.Split(’ ‘)」を指定し、同じ問題が発生しないようにしている。

これで、これまで発生していたエラーを調べてみると、全て解消されたことを確認できる。

問題が解消された

これで複数引数も処理できるようになった。本連載で育てている$PROFILEは、PowrShellから(WSLで動作する)Linuxをかなりシームレスに実行できるものになりつつある。

明日の”手抜き”を実現するために

PowerShellはLinuxのシェルのような使い方ができるが、実際にはオブジェクト指向ベースのプログラミング言語にシェルっぽいシンタックスを被せたような作りになっており、プログラミング言語としての細かい規則がたくさんある。もし少し凝ったことをしたいのであれば、それらの規則をきっちり把握しておかなければならない。

とはいえ、必要なドキュメントはすでにインターネット上で公開されており、ある程度プログラミングの経験があれば問題ないはずだ。強力なプログラミング言語としての側面を備えていることもあり、$PROFILEでかなり高度なことまで設定できてしまう。最初は苦労するかもしれないが、自分の用途に応じて鍛えられた$PROFILEは必ず力になってくれるはずだ。今日の努力は明日の”手抜き”につながる。ぜひともコツコツと$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()

#========================================================================
# Definition of Linux commands used via wsl
#========================================================================
$_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 command functions
ForEach($n in $_linux_command_paths) {
    $_n = (Split-Path -Leaf $n)
    $_linux_functions += "
        function $_n {
            if (`$Input.Length) {
                `$Input.Reset()
                `$Input | wsl $n `$(_path_to_linux `$Args).Split(' ')
            }
            else {
                wsl $n `$(_path_to_linux `$Args).Split(' ')
            }
        }"
}
Remove-Variable n
Remove-Variable _n

$_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_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
}

#less
function less {
    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 less
        wsl less $(_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 less $(_path_to_linux $Args).Split(' ')
    }
}

# ls
Get-Alias ls *> $null && Remove-Item alias:ls
function ls { wsl ls --color=auto $(_path_to_linux $Args).Split(' ') }
function ll { ls -l $(_path_to_linux $Args).Split(' ') }
function 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