前回の設定には問題アリ!

ここ数回はページャとして「less」を使えるようにする設定を模索してきた。lessはwslコマンド経由ではそのままでは使用できない。特に、パイプラインで流れてきたデータを閲覧する用途ではほとんど使えないことは説明してきた通りだ。そこで、本連載ではパイプライン経由で流れてきたデータをいったんファイルに書き出し、書き出したファイルをlessで開くことで動作しているように見せかける設定方法を紹介した。

そして前回はページャとして「lv」も使うために、処理を汎用的な関数にまとめる方法を解説した。しかし、実際には前回の設定にはいくつかの問題がある。例えば、次のようにページャの引数に何も指定しなかった場合にエラーが発生する。

ページャに引数が指定されていないとエラーが発生

前回まとめた設定スクリプトは次の通りだ。

# pager
function _linux_pager {
    # The first argument is the pager command name
    $pager = $Args[0]

    # Remove pager command from arguments to pass arguments to pager
    $Args = $Args[1..($Args.Length - 1)]

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

この書き方には少なくとも2つの問題がある。まず1つ目は、「$Args.Split(’ ‘)」の部分だ。引数が何もなかった場合、この部分はエラーになる。

もう1つは、less関数やlv関数から_linux_pager関数にパイプラインでデータを渡していないという点だ。前回の実行例では、これらの問題に引っかからないパターンだけを見せていた。

問題を解決する

これらの問題を解決するには、less関数およびlv関数でパイプラインを加味した処理の分離を行い、かつ「$Args.Split(’ ‘) 」を使わないように書き換えればよい。なるべく無駄を省きつつ、前回のスクリプトを改善するとすれば、次のような感じになる。less関数とlv関数側でパイプライン接続の有無に応じてパイプライン接続するかどうかを切り分け、$Argsもそのまま渡して「$Args.Split(’ ‘) 」の問題を回避している。

# pager
function _linux_pager_wi_pipe {
    $pager = $Args[0]
    $Args  = $Args[1]

    # Prepare temporary file path
    $_temp = New-TemporaryFile

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

    # Do less
    wsl $pager $(_path_to_linux $Args).Split(' ') $(_path_to_linux $_temp.ToString()).Split(' '    )

    # Delete unnecessary temporary file and variable
    Remove-Item $_temp
    Remove-Variable _temp
}
function _linux_pager_no_pipe {
    $pager = $Args[0]
    $Args  = $Args[1]

    wsl $pager $(_path_to_linux $Args).Split(' ')
}
function less {
    if ($Input.Length) {
        $Input.Reset();
        $Input |
        _linux_pager_wi_pipe "less" $Args
    }
    else {
        _linux_pager_no_pipe "less" $Args
    }
}
function lv {
    if ($Input.Length) {
        $Input.Reset();
        $Input |
        _linux_pager_wi_pipe "lv" $Args
    }
    else {
        _linux_pager_no_pipe "lv" $Args
    }
}

しかし、である。前回は、設定ファイルを短くするために汎用関数に処理をまとめようというのがそもそもの趣旨だった。上記スクリプトは使いたいページャを追加するごとに行数が増え、冗長極まりない。必然的に見づらくなる。

そこで、ほかのコマンドをまとめて関数にした部分の処理と同じ手法を採用することにする。つまり、

  1. ページャ関数を文字列として変数に格納
  2. 変数の内容を一時ファイルに書き出す
  3. 一時ファイルをPowerShellで読み込むことで関数定義として反映させる


ということで、まずはlessを例に、ページャ関数をシンプルにまとめてみよう。次のような感じになる。

# pager
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(' ')
    }
}

これを文字列として変数に格納するように書き換える。基の設定ファイルでは変数_linux_functionsに文字列として関数を保存しているので、ここにless関数とlv関数の定義を追加するようにする。

# pagers
$_linux_pagers = @("less", "lv")
ForEach($_n in $_linux_pagers) {
    $_linux_functions += "
        function $_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(' ')
            }
        }"
}

使用するページャは「$_linux_pagers = @(“less”, “lv”)」として配列にまとめた。この配列に新しいページャを追加するだけで、使用するページャを増やすことができる。これで前回よりもスッキリとページャの追加ができるようになったわけだ。最終的な設定ファイル($PROFILE)は付録として稿末にまとめておくので、そちらを参照していただきたい。

実行してみる

アップデートした$PROFILEで動作を確認してみよう。次のように、先ほどはエラーになって動かなかったコマンドを実行する。

先ほどは動かなかったコマンド

次のように動作していることを確認できる。

問題なく動作するようになった

同じく、lvコマンドについても先ほど動作しなかったコマンドを実行する。

先ほど動かなかったコマンド

これも次のように動作するようになったことを確認できる。

問題なく動作するようになった

「いったんファイルに書き出してから再度読み込むことで設定を反映させる」というのはトリッキーな方法ではあるのだが、それなりに使える手であることはおわかりいただけただろう。また、似たような処理をまとめる方法としても、関数に分割するより、こちらの手法のほうがスクリプトを短くする上では有効である。この辺りは、実際に作ってみないとわからない感覚だ。

付録: $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 pagers
$_linux_pagers = @("less", "lv")

# Linux PATH and 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 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(' ')
            }
        }"
}

# Generate Linux pagers functions
ForEach($_n in $_linux_pagers) {
    $_linux_functions += "
        function $_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(' ')
            }
        }"
}

$_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 n
Remove-Variable _n
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
}

# 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