Integrating Linux Commands into Windows with PowerShell and WSL

A typical question from Windows developers: “Why is there still no <ВСТАВЬТЕ ТУТ ЛЮБИМУЮ КОМАНДУ LINUX>? Whether it's a powerful swipe less or familiar tools grep or sed, Windows developers want easy access to these commands in their daily work.

Windows Subsystem for Linux (WSL) made a huge step forward in this regard. It allows you to call Linux commands from Windows by proxying them through wsl.exe (Eg, wsl ls). Although this is a significant improvement, this option suffers from a number of disadvantages.

  • ubiquitous addition wsl boring and unnatural.
  • Windows paths in arguments don't always work because backslashes are interpreted as escape characters, not directory separators.
  • Windows paths in arguments are not translated to the appropriate mount point in WSL.
  • Default settings in WSL profiles with aliases and environment variables are ignored.
  • Linux path completion is not supported.
  • Command completion is not supported.
  • Argument completion is not supported.

As a result, Linux commands are treated like second-class citizens under Windows—and harder to use than native commands. To equalize their rights, it is necessary to solve the listed problems.

PowerShell function wrappers

With the help of PowerShell function wrappers, we can add command completion and eliminate the need for prefixes wsl, translating Windows paths into WSL paths. Basic requirements for shells:

  • There must be one function wrapper for each Linux command with the same name.
  • The shell must recognize Windows paths passed as arguments and convert them to WSL paths.
  • The shell should call wsl with the appropriate Linux command to any pipeline input, and passing any command line arguments passed to the function.

Because this pattern can be applied to any command, we can abstract away the definition of these wrappers and dynamically generate them from a list of commands to import.

# 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 ' ')
    }
}
"@
}

List $command defines the commands to import. We then dynamically generate a function wrapper for each of them using the command Invoke-Expression (by first removing any aliases that would conflict with the function).

The function iterates over command line arguments, determines Windows paths using commands Split-Path и Test-Pathand then converts those paths to WSL paths. We run paths through a helper function Format-WslArgument, which will be defined later. It escapes special characters such as spaces and brackets that would otherwise be misinterpreted.

Finally, we pass wsl pipeline input and any command line arguments.

With these wrappers, you can call your favorite Linux commands in a more natural way without adding a prefix. wsl and without worrying about how the paths are converted:

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

The basic set of commands is shown here, but you can wrap any Linux command by simply adding it to the list. If you add this code to your profile PowerShell, these commands will be available to you in every PowerShell session, just like native commands!

Default Settings

It is customary in Linux to define aliases and/or environment variables in login profiles, setting default options for frequently used commands (for example, alias ls=ls -AFh or export LESS=-i). One of the disadvantages of proxying through a non-interactive shell wsl.exe - that the profiles are not loaded, so these options are not available by default (i.e. ls in WSL and wsl ls will behave differently with the alias defined above).

PowerShell provides $PSDefaultParameterValues, a standard mechanism for defining default parameters, but only for cmdlets and extended functions. Of course, it is possible to make extended functions out of our shells, but this introduces unnecessary complications (for example, PowerShell correlates partial parameter names (for example, -a correlates with -ArgumentList) that would conflict with Linux commands that take partial names as arguments), and the syntax for defining default values ​​would be inappropriate (defining default arguments requires the parameter name in the key, not just the command name).

However, with a slight change to our shells, we can implement a model similar to $PSDefaultParameterValues, and enable default options for Linux commands!

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

Conveying $WslDefaultParameterValues to the command line, we send the parameters via wsl.exe. The following shows how to add instructions to a PowerShell profile to configure default settings. Now we can do it!

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

Since the parameters are modeled after $PSDefaultParameterValues, You can easy to turn them off temporarily by setting the key "Disabled" in value $true. The added benefit of a separate hash table is the ability to disable $WslDefaultParameterValues separately from $PSDefaultParameterValues.

Argument Completion

PowerShell allows you to register argument completions with a command Register-ArgumentCompleter. Bash has powerful programmable means for auto-completion. WSL allows you to call bash from PowerShell. If we can register argument completions for our PowerShell function wrappers and call bash to generate completions, we'll get full argument completion with the same precision as bash itself!

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

The code is a bit dense without understanding some bash internals, but basically what we do is:

  • We register an argument completion for all of our function wrappers, passing a list $commands in parameter -CommandName for Register-ArgumentCompleter.
  • Map each command to a shell function that bash uses for autocompletion (to define autocompletion specifications in bash, use $F, short for complete -F <FUNCTION>).
  • Converting PowerShell Arguments $wordToComplete, $commandAst и $cursorPosition to the format expected by bash's auto-completion functions according to the specifications programmable auto-completion bash.
  • We compose a command line to pass to wsl.exe, which ensures that the environment is set up correctly, calls the appropriate autocomplete function, and prints line-by-line results.
  • Then we call wsl with the command line, we separate the output with line separators and generate for each CompletionResults, sorting them and escaping characters such as spaces and brackets that would otherwise be misinterpreted.

As a result, our Linux command shells will use exactly the same autocompletion as in bash! For example:

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

Each autocomplete substitutes values ​​specific to the previous argument, reading configuration data such as known hosts from WSL!

<TAB> will cycle through the parameters. <Ctrl + пробел> will show all available options.

Also, since we now have bash autocompletion working, you can autocomplete Linux paths directly in PowerShell!

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

In cases where bash completion fails, PowerShell falls back to the default system with Windows paths. Thus, in practice, you can use both ways at the same time at your discretion.

Conclusion

With PowerShell and WSL, we can integrate Linux commands into Windows as native applications. There is no need to look for Win32 builds or Linux utilities, or interrupt your workflow by switching to a Linux shell. Just install WSL, customize PowerShell profile и list the commands you want to import! Rich auto-completion for Linux and Windows command parameters and file paths is a feature not even found in native Windows commands today.

The full source code described above, as well as additional guidelines for including it in your workflow, are available. here.

What Linux commands do you find most useful? What other familiar things are missing when working in Windows? Write in the comments or on GitHub!

Source: habr.com

Add a comment