やっぱりOut-Hostはlessの代わりにならない

前々回、WSLで動作するlessコマンドはパイプラインで接続するとPowerShellからはうまく使えないことを取り上げた。そのときは次の関数を作成することでlessが動作するようにした。

#less
function less {
    if ($Input.Length) {
        $Input.Reset()
        $Input | Out-Host -Paging
    }
    else {
        wsl less $(_path_to_linux $Args)
    }
}

上記関数では、ファイルパスが指定されている場合にはLinuxのlessコマンドを実行し、パイプライン経由でデータが流れてくる場合にはPowerShellのOut-Hostコマンドレットに流し込む。「パイプラインで接続された場合に動作しないよりは、機能が減っても動いたほうがましである」という考え方が基になっている。

しかし、やはりコマンド名としてlessを実行しているのに、lessではないOut-Hostが動作するというのはストレスが溜まる。lessの提供している機能がほとんど用意されていないので、実際に使ってみると逆にストレスが溜まることがわかった。

使っていくとさらにほかの問題があることもわかった。例えば次のスクリーンショットは「ls -l | less」を実行したものだ。実際には先ほど作成した関数によって「Out-Host -Pasing」にデータが流れている。

前々回に作成したless関数の動作サンプル

一見動作するように見えるが、この場合の処理は「wsl ls -l | Out-Host -Pasing」という、wslコマンドからPowerShellネイティブなコマンドレットにデータが流れるようになっており、日本語の処理がうまくいかない。前回、文字化けが起きないように設定したが、この使い方では以下のように文字化けが発生してしまう。

Out-Hostコマンドレットに流し込むと文字化けが発生

「lessの機能が使えないとストレスが溜まる」「日本語出力が文字化けするのでストレスが溜まる」――ということで、このやり方は結局いまいちだった。動作しないよりはましだが、許容できるレベルではなかったようだ。今回はこの部分を改善して動作するようにする。

パイプライン経由でもlessが動くようにする

ここではパイプラインで接続された場合にもWSLのlessコマンドを実行するようにしたい。ただし、これまでに取り上げたようにパイプライン経由でwsl lessへ接続すると制御用のキーがlessコマンドまで届かないので、制御ができない。

ここではパイプライン経由で流れてくるデータをいったんファイルに書き出し、そのファイルのパスを指定してWSLのlessコマンドを実行するように処理を変更する。lessコマンドにファイルを指定して動作させる場合には問題なく動くことはわかっているのだから、問題のない動作になるようにしてしまおう、ということだ。

$PROFILEでは、Linuxコマンドを関数として定義した後、それをPowerShellに反映させる処理を行っているわけだが、この処理の過程で一時ファイルの作成と削除を行っている。この処理を流用すれば、上記を実現することができる。

まず、一時ファイルを作成して利用している既存の設定を確認する。

# 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

ここから必要な処理だけを取り出して整理すると、次のようになる。

# 一時ファイルパスの作成
$_temp = New-TemporaryFile

# パイプラインを一時ファイルへ書き出し
$Input | Out-File $_temp

# 一時ファイルと変数を削除
Remove-Item $_temp
Remove-Variable _temp

この処理を最初に取り上げたless関数のなかに組み込んで、パイプラインの内容を一時ファイルに書き出して「wsl 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)
    }
}

設定を$PROFILEに反映させたら実行してみよう。「ls -l | less」ように「ls -l」の出力結果をlessに流し込むと、/mnt/c/Users/daichi/AppData/Local/Temp/tmpE85.tmpという一時ファイルをlessで閲覧していることがわかる。ファイルを指定して動作しているので、キーボードからのless操作を問題なく行うことができる。

パイプラインの内容を一時ファイルに書き出してからlessコマンドが実行されていることがわかる

Linuxネイティブなlessコマンドが動作しているので、次のように検索機能も使うことができる。普段lessの操作に使っているキーがそのまま使用できるのでストレスフリーだ。

lessの検索キーも使用できる

検索キーワードがハイライトされている

そして、次のように日本語の文字化け問題も解決している。

日本語の文字化け問題も解決

lessはかなり頻用するコマンドだ。このコマンドがそのまま利用できるようになったことで、かなりストレスを減らすことができる。

パイプラインを一時ファイルに書き出すことは……

UNIX系のOS、特にカーネルの動作に詳しい方であれば、パイプラインで流れてくるデータをいったんファイルに書き出すのはためらう方も多いのではないだろうか。なぜなら、まず、このアプローチを取ると動作がとても重くなる。パイプラインを流しながら処理を行えばカーネルはうまく処理を行ってくれるが、いったんファイルに書き出すとなると、この部分が性能のボトルネックになる。

しかし、PowerShellのパイプラインの場合にはこの考え方はあまり問題にならない。主な理由は次の通りだ。

  • PowerShellのパイプラインにはオブジェクトが流れており、UNIX系OSにのパイプのように複数プロセスが同時に通信を行う仕組みになっていない。いったん全てのデータをオブジェクトとして作成し、それを次のコマンドやコマンドレットに渡す、という仕組みになっている。このため、そもそも処理が遅く、処理速度を気にしても意味がない。
  • そして、PowerShellはそもそも処理速度を期待するタイプのソフトウエアではない。


PowerShellは管理用のシェルであり、処理速度を期待する対応のソフトウエアではない。多少の処理速度の遅さを気にするよりも、操作することでストレスが溜まるほうが問題である。

今回はlessコマンドにパイプからのデータが流れてきている場合に、いったん一時ファイルに出力してから操作する方法に変更した。これでまた一つ改善が進み、より扱いやすくなったはずだ。

付録: $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)
            }
            else {
                wsl $n `$(_path_to_linux `$Args)
            }
        }"
}
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 $_temp.ToString())

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

# 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) }

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