関数定義を自動化しよう

前回までに、Windows 10でLinuxコマンドを利用するための関数を定義する方法を取り上げた。そこで解説したように、Windows 10のパスをLinuxのパスに変換することで、まるでネイティブなWindows 10のコマンドのようにLinuxのコマンドを利用することができる。

パスの変換を行うユーザー定義関数_path_to_linuxを使った関数は次の通りだ。使いたいLinuxコマンドが出てきたら、この部分を追加していけばよい。

function less {
    wsl less $(_path_to_linux $Args)
}
function lv {
    wsl lv $(_path_to_linux $Args)
}
function vi {
    wsl vi $(_path_to_linux $Args)
}
function vim {
    wsl vim $(_path_to_linux $Args)
}
function nvim {
    wsl nvim $(_path_to_linux $Args)
}
function tree {
    wsl tree $(_path_to_linux $Args)
}
function git {
    wsl git $(_path_to_linux $Args)
}

使いたいコマンドが少ないうちはこれでよいのだが、増えてくるとこのコードが何百行にも及ぶようになる。シンプルなものなのでそれでも良いのだが、今回はこの部分のコードを少し書き換えて、自動的に関数定義を行うように変更する。いくつかのTipsを紹介することになるので、PowerShellスクリプトの使い方としても面白いサンプルではないかと思う。

関数定義を自動化する方法

まず、次のような配列を定義することを考える。「使いたいLinuxコマンドが増えたら、ここにそのコマンド名を追加すれば完了」というかたちにしたい。この配列から自動的に関数を定義していくわけだ。

$_linux_command_names = @('less', 'lv', 'vi', 'vim', 'nvim', 'tree', 'git')

PowerShellでは配列名に変数を使うことができない。ここでは次の方法で関数を作成し、PowerShellに反映させる方法を取る。

  1. 関数定義をファイルに書き出す
  2. ファイルを読み込む


PowerShellではほかのシェルと同じように「. ファイルパス.ps1」のようにすることで、指定したファイルの中身を読み込んで処理することができるので、この特性を利用する。関数定義をファイルに書き出し、それを読み込むことで関数を定義させるわけだ。これなら動的に関数を定義していくことができる。

先ほどの処理をもう少し詳しく書くと、次のようになる。

  1. 関数定義を変数に保存する
  2. 一時ファイルを作成する
  3. 一時ファイルに関数定義を書き出す
  4. 一時ファイルを読み込んで処理する
  5. 一時ファイルを削除する
  6. 作業に用いた不要な変数を削除する


一時ファイルは拡張子が「ps1」である必要があるのだが、PowerShellが標準で提供している一時ファイル作成用のコマンドレットでは拡張子を指定することができない。このため、次のように手順を変更し、PowerShellの一時ファイル作成用コマンドレットを使いつつ、拡張子が「ps1」のファイルを用意して利用するようにする。

これを加味して手順を整理すると次のようになる。

  1. 関数定義を変数に保存する
  2. 一時ファイル1を作成する
  3. 一時ファイル1に拡張子「ps1」を付けた一時ファイル2を作成する
  4. 一時ファイル1を削除する
  5. 一時ファイル2に関数定義を書き出す
  6. 一時ファイル2を読み込んで処理する
  7. 一時ファイル2を削除する
  8. 作業に用いた不要な変数を削除する


それでは上記手順を実現するスクリプトを見ていこう。まず、次のように変数に関数定義を割り当てていく。

ForEach($n in $_linux_command_names) {
    $_linux_functions += "
        function $n {
            wsl $n `$(_path_to_linux `$Args)
        }"
}

上記コードの特徴は、変数に保存する段階で「$(ドル記号)」そのものを含める必要があるため、ドル記号の前にバッククォートを書いていることだ。バッククォートを書くとその後のドル記号は変数を意味せず、ただの文字としてのドル記号として処理される。これで変数に保存する文字列にドル記号を含めることができる。

_path_to_linux関数は上記手順で読み込ませても$PROFILEにそのまま書いておいてもどちらでもよいのだが、ここではヒアドキュメントの使い方を示す例として上記と同じ変数に含めておく。次のように記述しておけばよい。

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

ここではヒアドキュメントとして「@’」と「’@」で囲むことがポイントだ。「@’」と「’@」で囲むとドル記号が文字として解釈されるので、前述したようにバッククォートでエスケープする必要がなくなり、記述が煩雑にならなくて済む。なお、「@”」と「”@」で囲むとドル機能は変数として展開される。今回は「@’」と「’@」が適切だ。

生成されたファイルのフルパスを取得し、これに拡張子「ps1」を加えて関数定義を書き出すファイルのパスを作る。一時ファイルは、次のようにNew-TemporaryFileコマンドレットで作成する。

$_temp = New-TemporaryFile
$_temp_ps1 = $_temp.FullName + ".ps1"
Remove-Item $_temp

関数定義を上記処理で生成したパスへ書き出し、「.」で読み込んで関数定義を実施する。

$_linux_functions | Out-File $_temp_ps1
. $_temp_ps1
Remove-Item $_temp_ps1

この段階でやりたかったことはできたので、不要な変数を削除してここの処理を完了とする。

Remove-Variable _temp
Remove-Variable _temp_ps1

上記流れを1つのスクリプトにまとめると次のようになる。

#========================================================================
# Linux command definition used via wsl
#========================================================================
$_linux_command_names = @('less', 'lv', 'vi', 'vim', 'nvim', 'tree', 'git')

# Generate Linux command function
ForEach($n in $_linux_command_names) {
    $_linux_functions += "
        function $n {
            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

長くなったように見えるかもしれないが、該当部分のソースコード量はそれほど変わっていない。今後はコマンドを追加してもこの部分はほとんど長くならない。定義する関数を変更したくなった場合も、1カ所書き換えるだけで全ての関数定義を変更できるので、何かと便利だ。

実行サンプル

今回のサンプルではLinuxコマンドと関数定義についてはそのまま変数に残してある。PowerShell起動後に次のようにそれらを表示させることができる。

$PROFILEで定義された変数を確認

PowerShellの関数を次のように表示させることで、LinuxコマンドがPowerShellの関数として定義されていることも確認できる。

LinuxコマンドがPowerShellコマンドとして定義されていることを確認

なかなか汎用的に使える状態になってきたのではないだろうか。

付録: $PROFILE

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

#========================================================================
# Linux command definition used via wsl
#========================================================================
$_linux_command_names = @('less', 'lv', 'vi', 'vim', 'nvim', 'tree', 'git')

# Generate Linux command function
ForEach($n in $_linux_command_names) {
    $_linux_functions += "
        function $n {
            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

# Individual Linux command function definition
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
}

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

#========================================================================
# Short name definition
#========================================================================
function ll { Get-ChildItem -Force }
function la { Get-ChildItem -Force }