使用 PowerShell 和 WSL 将 Linux 命令集成到 Windows 中

Windows 开发人员的一个典型问题是:“为什么仍然没有 <ВСТАВЬТЕ ТУТ ЛЮБИМУЮ КОМАНДУ LINUX>?。 无论是强力的滑动 less 或者熟悉的工具 grep или sed,Windows 开发人员希望在日常工作中轻松访问这些命令。

适用于 Linux 的 Windows 子系统 (WSL) 在这方面取得了巨大的进步。 它允许您通过代理从 Windows 调用 Linux 命令 wsl.exe (例如, wsl ls)。 尽管这是一个显着的改进,但该选项存在许多缺点。

  • 无处不在的添加 wsl 乏味且不自然。
  • 参数中的 Windows 路径并不总是有效,因为反斜杠被解释为转义字符而不是目录分隔符。
  • 参数中的 Windows 路径不会转换为 WSL 中相应的挂载点。
  • 具有别名和环境变量的 WSL 配置文件不遵循默认设置。
  • 不支持 Linux 路径补全。
  • 不支持命令完成。
  • 不支持参数完成。

因此,Linux 命令在 Windows 下被视为二等公民,并且比本机命令更难使用。 要实现权利平等,就必须解决所列问题。

PowerShell 函数包装器

使用 PowerShell 函数包装器,我们可以添加命令完成并消除对前缀的需要 wsl,将 Windows 路径转换为 ​​WSL 路径。 对外壳的基本要求:

  • 对于每一个 Linux 命令,都必须有一个同名的函数包装器。
  • shell 必须识别作为参数传递的 Windows 路径并将其转换为 WSL 路径。
  • shell 应该调用 wsl 使用适当的 Linux 命令连接到任何管道输入,并将任何命令行参数传递给函数。

由于此模式可以应用于任何命令,因此我们可以抽象这些包装器的定义,并从要导入的命令列表动态生成它们。

# The commands to import.
$commands = "awk", "emacs", "grep", "head", "less", "ls", "man", "sed", "seq", "ssh", "tail", "vim"
 
# Register a function for each command.
$commands | ForEach-Object { Invoke-Expression @"
Remove-Alias $_ -Force -ErrorAction Ignore
function global:$_() {
    for (`$i = 0; `$i -lt `$args.Count; `$i++) {
        # If a path is absolute with a qualifier (e.g. C:), run it through wslpath to map it to the appropriate mount point.
        if (Split-Path `$args[`$i] -IsAbsolute -ErrorAction Ignore) {
            `$args[`$i] = Format-WslArgument (wsl.exe wslpath (`$args[`$i] -replace "", "/"))
        # If a path is relative, the current working directory will be translated to an appropriate mount point, so just format it.
        } elseif (Test-Path `$args[`$i] -ErrorAction Ignore) {
            `$args[`$i] = Format-WslArgument (`$args[`$i] -replace "", "/")
        }
    }
 
    if (`$input.MoveNext()) {
        `$input.Reset()
        `$input | wsl.exe $_ (`$args -split ' ')
    } else {
        wsl.exe $_ (`$args -split ' ')
    }
}
"@
}

$command 定义导入命令。 然后,我们使用以下命令动态为每个函数生成一个函数包装器 Invoke-Expression (首先删除与函数冲突的任何别名)。

该函数迭代命令行参数,使用命令确定 Windows 路径 Split-Path и Test-Path然后将这些路径转换为 ​​WSL 路径。 我们通过辅助函数运行路径 Format-WslArgument,我们稍后会定义。 它转义特殊字符,例如空格和括号,否则会被误解。

最后,我们传达 wsl 管道输入和任何命令行参数。

使用这些包装器,您可以以更自然的方式调用您最喜欢的 Linux 命令,而无需添加前缀 wsl 并且不用担心路径如何转换:

  • man bash
  • less -i $profile.CurrentUserAllHosts
  • ls -Al C:Windows | less
  • grep -Ein error *.log
  • tail -f *.log

此处显示了基本命令集,但您只需将任何 Linux 命令添加到列表中即可为任何 Linux 命令创建 shell。 如果您将此代码添加到您的 轮廓 PowerShell,这些命令将在每个 PowerShell 会话中可供您使用,就像本机命令一样!

默认设置

在 Linux 中,通常在登录配置文件中定义别名和/或环境变量,为常用命令设置默认参数(例如, alias ls=ls -AFh или export LESS=-i)。 通过非交互式 shell 进行代理的缺点之一 wsl.exe - 配置文件未加载,因此默认情况下这些选项不可用(即 ls 在 WSL 和 wsl ls 与上面定义的别名的行为会有所不同)。

PowerShell 提供 $PSDefaultParameterValues,一种用于定义默认参数的标准机制,但仅适用于 cmdlet 和高级函数。 当然,我们可以在 shell 中创建高级函数,但这会带来不必要的复杂性(例如,PowerShell 会关联部分参数名称(例如, -a 相关于 -ArgumentList),这会与采用部分名称作为参数的 Linux 命令冲突),并且定义默认值的语法将不是最合适的(默认参数需要键中参数的名称,而不仅仅是命令名称) 。

但是,通过对 shell 进行轻微修改,我们可以实现类似于以下的模型 $PSDefaultParameterValues,并启用 Linux 命令的默认选项!

function global:$_() {
    …
 
    `$defaultArgs = ((`$WslDefaultParameterValues.$_ -split ' '), "")[`$WslDefaultParameterValues.Disabled -eq `$true]
    if (`$input.MoveNext()) {
        `$input.Reset()
        `$input | wsl.exe $_ `$defaultArgs (`$args -split ' ')
    } else {
        wsl.exe $_ `$defaultArgs (`$args -split ' ')
    }
}

通过 $WslDefaultParameterValues 到命令行,我们通过发送参数 wsl.exe。 下面显示了如何向 PowerShell 配置文件添加说明以配置默认设置。 现在我们可以做到了!

$WslDefaultParameterValues["grep"] = "-E"
$WslDefaultParameterValues["less"] = "-i"
$WslDefaultParameterValues["ls"] = "-AFh --group-directories-first"

由于参数是根据 $PSDefaultParameterValues, 您可以 禁用它们很容易 暂时通过安装密钥 "Disabled" 转化为意义 $true。 单独的哈希表的另一个好处是能够禁用 $WslDefaultParameterValues$PSDefaultParameterValues.

参数完成

PowerShell 允许您使用以下命令注册参数预告片 Register-ArgumentCompleter。 Bash 拥有强大的 可编程自动完成工具。 WSL 允许您从 PowerShell 调用 bash。 如果我们可以为 PowerShell 函数包装器注册参数完成并调用 bash 来生成完成,那么我们将获得与 bash 本身具有相同精度的完整参数完成!

# Register an ArgumentCompleter that shims bash's programmable completion.
Register-ArgumentCompleter -CommandName $commands -ScriptBlock {
    param($wordToComplete, $commandAst, $cursorPosition)
 
    # Map the command to the appropriate bash completion function.
    $F = switch ($commandAst.CommandElements[0].Value) {
        {$_ -in "awk", "grep", "head", "less", "ls", "sed", "seq", "tail"} {
            "_longopt"
            break
        }
 
        "man" {
            "_man"
            break
        }
 
        "ssh" {
            "_ssh"
            break
        }
 
        Default {
            "_minimal"
            break
        }
    }
 
    # Populate bash programmable completion variables.
    $COMP_LINE = "`"$commandAst`""
    $COMP_WORDS = "('$($commandAst.CommandElements.Extent.Text -join "' '")')" -replace "''", "'"
    for ($i = 1; $i -lt $commandAst.CommandElements.Count; $i++) {
        $extent = $commandAst.CommandElements[$i].Extent
        if ($cursorPosition -lt $extent.EndColumnNumber) {
            # The cursor is in the middle of a word to complete.
            $previousWord = $commandAst.CommandElements[$i - 1].Extent.Text
            $COMP_CWORD = $i
            break
        } elseif ($cursorPosition -eq $extent.EndColumnNumber) {
            # The cursor is immediately after the current word.
            $previousWord = $extent.Text
            $COMP_CWORD = $i + 1
            break
        } elseif ($cursorPosition -lt $extent.StartColumnNumber) {
            # The cursor is within whitespace between the previous and current words.
            $previousWord = $commandAst.CommandElements[$i - 1].Extent.Text
            $COMP_CWORD = $i
            break
        } elseif ($i -eq $commandAst.CommandElements.Count - 1 -and $cursorPosition -gt $extent.EndColumnNumber) {
            # The cursor is within whitespace at the end of the line.
            $previousWord = $extent.Text
            $COMP_CWORD = $i + 1
            break
        }
    }
 
    # Repopulate bash programmable completion variables for scenarios like '/mnt/c/Program Files'/<TAB> where <TAB> should continue completing the quoted path.
    $currentExtent = $commandAst.CommandElements[$COMP_CWORD].Extent
    $previousExtent = $commandAst.CommandElements[$COMP_CWORD - 1].Extent
    if ($currentExtent.Text -like "/*" -and $currentExtent.StartColumnNumber -eq $previousExtent.EndColumnNumber) {
        $COMP_LINE = $COMP_LINE -replace "$($previousExtent.Text)$($currentExtent.Text)", $wordToComplete
        $COMP_WORDS = $COMP_WORDS -replace "$($previousExtent.Text) '$($currentExtent.Text)'", $wordToComplete
        $previousWord = $commandAst.CommandElements[$COMP_CWORD - 2].Extent.Text
        $COMP_CWORD -= 1
    }
 
    # Build the command to pass to WSL.
    $command = $commandAst.CommandElements[0].Value
    $bashCompletion = ". /usr/share/bash-completion/bash_completion 2> /dev/null"
    $commandCompletion = ". /usr/share/bash-completion/completions/$command 2> /dev/null"
    $COMPINPUT = "COMP_LINE=$COMP_LINE; COMP_WORDS=$COMP_WORDS; COMP_CWORD=$COMP_CWORD; COMP_POINT=$cursorPosition"
    $COMPGEN = "bind `"set completion-ignore-case on`" 2> /dev/null; $F `"$command`" `"$wordToComplete`" `"$previousWord`" 2> /dev/null"
    $COMPREPLY = "IFS=`$'n'; echo `"`${COMPREPLY[*]}`""
    $commandLine = "$bashCompletion; $commandCompletion; $COMPINPUT; $COMPGEN; $COMPREPLY" -split ' '
 
    # Invoke bash completion and return CompletionResults.
    $previousCompletionText = ""
    (wsl.exe $commandLine) -split 'n' |
    Sort-Object -Unique -CaseSensitive |
    ForEach-Object {
        if ($wordToComplete -match "(.*=).*") {
            $completionText = Format-WslArgument ($Matches[1] + $_) $true
            $listItemText = $_
        } else {
            $completionText = Format-WslArgument $_ $true
            $listItemText = $completionText
        }
 
        if ($completionText -eq $previousCompletionText) {
            # Differentiate completions that differ only by case otherwise PowerShell will view them as duplicate.
            $listItemText += ' '
        }
 
        $previousCompletionText = $completionText
        [System.Management.Automation.CompletionResult]::new($completionText, $listItemText, 'ParameterName', $completionText)
    }
}
 
# Helper function to escape characters in arguments passed to WSL that would otherwise be misinterpreted.
function global:Format-WslArgument([string]$arg, [bool]$interactive) {
    if ($interactive -and $arg.Contains(" ")) {
        return "'$arg'"
    } else {
        return ($arg -replace " ", " ") -replace "([()|])", ('$1', '`$1')[$interactive]
    }
}

代码有点密集,不了解 bash 的一些内部函数,但基本上我们所做的是这样的:

  • 通过传递列表为所有函数包装器注册参数完成器 $commands 在参数中 -CommandNameRegister-ArgumentCompleter.
  • 我们将每个命令映射到 bash 用于自动完成的 shell 函数(为了定义自动完成规范,bash 使用 $F,缩写为 complete -F <FUNCTION>).
  • 转换 PowerShell 参数 $wordToComplete, $commandAst и $cursorPosition 根据规范转换为 bash 自动补全函数所期望的格式 可编程自动完成 猛击。
  • 我们编写一个命令行来传输到 wsl.exe,它确保环境设置正确,调用适当的自动完成函数,并以逐行方式输出结果。
  • 然后我们打电话 wsl 使用命令行,我们用行分隔符分隔输出并为每个生成 CompletionResults,对它们进行排序并转义空格和括号等可能会被误解的字符。

因此,我们的 Linux 命令 shell 将使用与 bash 完全相同的自动完成功能! 例如:

  • ssh -c <TAB> -J <TAB> -m <TAB> -O <TAB> -o <TAB> -Q <TAB> -w <TAB> -b <TAB>

每个自动补全提供特定于前一个参数的值,从 WSL 读取配置数据,例如已知主机!

<TAB> 将循环浏览参数。 <Ctrl + пробел> 将显示所有可用选项。

另外,由于我们现在有了 bash 自动补全功能,您可以直接在 PowerShell 中自动补全 Linux 路径!

  • less /etc/<TAB>
  • ls /usr/share/<TAB>
  • vim ~/.bash<TAB>

如果 bash 自动补全未产生任何结果,PowerShell 将恢复为系统默认 Windows 路径。 因此,在实践中,您可以自行决定同时使用这两条路径。

结论

使用 PowerShell 和 WSL,我们可以将 Linux 命令作为本机应用程序集成到 Windows 中。 无需搜索 Win32 版本或 Linux 实用程序,也无需通过转至 Linux shell 来中断您的工作流程。 只是 安装 WSL, 配置 PowerShell 简介 и 列出您要导入的命令! Linux 和 Windows 命令参数和文件路径的丰富自动补全功能甚至在当今的本机 Windows 命令中也不可用。

上述完整源代码以及将其合并到您的工作流程中的附加指南均已提供 这里.

您认为哪些 Linux 命令最有用? 在 Windows 中工作时还缺少哪些常见的东西? 写在评论或 在GitHub上!

来源: habr.com

添加评论