Mike
26 de Setembro, 2019
Uma questão comum aos programadores do Windows é “porque é que o Windows não tem <INSERT FAVORITE LINUX COMMAND HERE>
ainda?”. Quer desejem um pager poderoso como less
ou desejem usar comandos familiares como grep
ou sed
, os programadores do Windows desejam um acesso fácil a estes comandos como parte do seu fluxo de trabalho principal.
O Subsistema Windows para Linux (WSL) foi um enorme passo em frente aqui, permitindo aos programadores ligar para os comandos Linux a partir do Windows, através de proxy através de wsl.exe
(por exemplo wsl ls
). Embora haja uma melhoria significativa, a experiência é inexistente em vários aspectos:
- Prefixar comandos com
wsl
é enfadonho e antinatural - Caminhos das janelas passados porque os argumentos não resolvem muitas vezes devido a contrabarras serem interpretados como caracteres de fuga em vez de separadores de directórios
- Caminhos das janelas passados porque os argumentos não resolvem muitas vezes devido a não serem traduzidos para o ponto de montagem dentro do WSL
- Parâmetros padrão definidos nos perfis de login WSL com pseudónimos e variáveis de ambiente não são honrados
- A conclusão do caminho do Linux não é suportada
- A conclusão do comando não é suportada
- A conclusão do argumento não é suportada
O resultado destas deficiências é que os comandos do Linux parecem ser os segundos…cidadãos de classe para o Windows e são mais difíceis de usar do que deveriam ser. Para que um comando se sinta como um comando nativo do Windows, teremos de abordar estas questões.
PowerShell Function Wrappers
Podemos remover a necessidade de prefixar comandos com wsl
, tratar da tradução dos caminhos do Windows para caminhos WSL, e suportar a conclusão do comando com os invólucros da função PowerShell. Os requisitos básicos dos invólucros são:
- Deve haver um wrapper de função por comando Linux com o mesmo nome do comando
- O wrapper deve reconhecer os caminhos do Windows passados como argumentos e traduzi-los para caminhos WSL
- O wrapper deve invocar
wsl
com o comando Linux correspondente, piping em qualquer entrada de pipeline e passando qualquer argumento de linha de comando passado para a função
p>Desde que este modelo pode ser aplicado a qualquer comando, podemos abstrair a definição destes wrappers e gerá-los dinamicamente a partir de uma lista de comandos a importar.
# 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 Ignorefunction 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 -IsAbsolute -ErrorAction Ignore) { `$args = Format-WslArgument (wsl.exe wslpath (`$args -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 -ErrorAction Ignore) { `$args = Format-WslArgument (`$args -replace "\\", "/") } } if (`$input.MoveNext()) { `$input.Reset() `$input | wsl.exe $_ (`$args -split ' ') } else { wsl.exe $_ (`$args -split ' ') }}"@}
O $command
lista define os comandos a importar. Depois geramos dinamicamente o invólucro da função para cada um usando o comando Invoke-Expression
(primeiro removendo quaisquer pseudónimos que possam entrar em conflito com a função).
A função percorre os argumentos da linha de comando, identifica os caminhos do Windows usando os comandos Split-Path
e Test-Path
, depois converte esses caminhos para caminhos WSL. Executamos os caminhos através de uma função de ajuda que definiremos mais tarde chamada Format-WslArgument
que escapa a caracteres especiais como espaços e parênteses que de outra forma seriam mal interpretados.
Finalmente, passamos a entrada de pipeline e quaisquer argumentos de linha de comando para wsl
.
Com estes invólucros de funções no lugar, podemos agora chamar os nossos comandos Linux favoritos de uma forma mais natural sem ter de os prefixar com wsl
ou preocuparmo-nos com a forma como os caminhos do Windows são traduzidos para caminhos WSL:
man bash
less -i $profile.CurrentUserAllHosts
ls -Al C:\Windows\ | less
grep -Ein error *.log
tail -f *.log
Um conjunto inicial de comandos é mostrado aqui, mas é possível gerar um wrapper para qualquer comando Linux simplesmente adicionando-o à lista. Se adicionar este código ao seu perfil PowerShell, estes comandos estarão disponíveis em cada sessão PowerShell tal como os comandos nativos!
Parâmetros Padrão
É comum no Linux definir aliases e/ou variáveis de ambiente dentro de perfis de login para definir parâmetros padrão para comandos que usa frequentemente (por exemplo alias ls=ls -AFh
ou export LESS=-i
). Um dos inconvenientes da proxying através de uma shell não-interactiva via wsl.exe
é que os perfis de login não são carregados, pelo que estes parâmetros predefinidos não estão disponíveis (por exemplo ls
dentro da WSL e wsl ls
comportar-se-iam de forma diferente com o alias definido acima).
PowerShell fornece $PSDefaultParameterValues
, um mecanismo padrão para definir valores de parâmetros padrão, mas apenas para cmdlets e funções avançadas. Transformar os nossos invólucros de funções em funções avançadas é possível mas introduz complicações (por exemplo, PowerShell combina com nomes parciais de parâmetros (como combinar -a
para -ArgumentList
), o que entrará em conflito com os comandos Linux que aceitam os nomes parciais como argumentos), e a sintaxe para definir valores por defeito seria menos do que ideal para este cenário (exigindo o nome de um parâmetro na chave para definir os argumentos por defeito em vez de apenas o nome do comando).
Com uma pequena alteração aos nossos invólucros de funções, podemos introduzir um modelo semelhante a $PSDefaultParameterValues
e activar parâmetros por defeito para comandos Linux!
function global:$_() { … `$defaultArgs = ((`$WslDefaultParameterValues.$_ -split ' '), "") if (`$input.MoveNext()) { `$input.Reset() `$input | wsl.exe $_ `$defaultArgs (`$args -split ' ') } else { wsl.exe $_ `$defaultArgs (`$args -split ' ') }}
Ao passar $WslDefaultParameterValues
para a linha de comando que enviamos através de wsl.exe
, pode agora adicionar declarações como abaixo ao seu perfil PowerShell para configurar parâmetros por defeito!
$WslDefaultParameterValues = "-E"$WslDefaultParameterValues = "-i"$WslDefaultParameterValues = "-AFh --group-directories-first"
Desde que isto seja modelado após $PSDefaultParameterValues
, pode desactivá-los temporariamente facilmente definindo a tecla "Disabled"
para $true
. Uma tabela hash separada tem o benefício adicional de poder desactivar $WslDefaultParameterValues
separadamente de $PSDefaultParameterValues
.
Argumento Completo
PowerShell permite registar os complementos de argumento com o comando Register-ArgumentCompleter
. Bash dispõe de poderosas instalações de conclusão programáveis. O WSL permite-lhe chamar para a bash a partir do PowerShell. Se pudermos registar completadores de argumentos para os nossos invólucros de função PowerShell e chamar para bash para gerar os complementos, podemos obter uma conclusão rica de argumentos com a mesma fidelidade que dentro da própria 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.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.Extent if ($cursorPosition -lt $extent.EndColumnNumber) { # The cursor is in the middle of a word to complete. $previousWord = $commandAst.CommandElements.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.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.Extent $previousExtent = $commandAst.CommandElements.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.Extent.Text $COMP_CWORD -= 1 } # Build the command to pass to WSL. $command = $commandAst.CommandElements.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 + $_) $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 ::new($completionText, $listItemText, 'ParameterName', $completionText) }}# Helper function to escape characters in arguments passed to WSL that would otherwise be misinterpreted.function global:Format-WslArgument($arg, $interactive) { if ($interactive -and $arg.Contains(" ")) { return "'$arg'" } else { return ($arg -replace " ", "\ ") -replace "()", ('\$1', '`$1') }}
O código é um pouco denso sem uma compreensão de alguns internos da bash, mas basicamente:
- Registamos o argumento mais completo para todos os nossos invólucros de funções passando o
$commands
lista para o-CommandName
parâmetro deRegister-ArgumentCompleter
- Mapeamos cada comando para a função bash usa para completar (
$F
que tem o nome decomplete -F <FUNCTION>
usado para definir as especificações de conclusão em bash) - convertemos a função PowerShell
$wordToComplete
$commandAst
, e$cursorPosition
argumentos para o formato esperado pelas funções de conclusão bash programáveis de acordo com a especificação de conclusão bash programável - Construímos uma linha de comando que podemos passar para
wsl.exe
que assegura que o ambiente de conclusão é configurado correctamente, invoca a função de conclusão apropriada, depois produz uma string contendo os resultados de conclusão separados por novas linhas - Invocamos então
wsl
com a linha de comando, dividimos a string de saída no novo separador de linhas, depois gerarCompletionResults
para cada um, ordenando-os, e escapando a caracteres como espaços e parênteses que de outra forma seriam mal interpretados
O resultado final disto é agora os nossos invólucros de comando Linux usarão exactamente o mesmo preenchimento que a bash usa! Por exemplo:
ssh -c <TAB> -J <TAB> -m <TAB> -O <TAB> -o <TAB> -Q <TAB> -w <TAB> -b <TAB>
Cada conclusão fornecerá valores específicos ao argumento anterior, lendo em dados de configuração como hospedeiros conhecidos de dentro da WSL!
<TAB>
irá circular através de opções. <Ctrl + Space>
mostrará todas as opções disponíveis.
Adicionalmente, uma vez que a conclusão da bash está agora no comando, pode resolver caminhos Linux directamente dentro do PowerShell!
less /etc/<TAB>
ls /usr/share/<TAB>
vim ~/.bash<TAB>
Em casos em que a conclusão da bash não devolve quaisquer resultados, PowerShell volta à sua conclusão padrão que resolverá os caminhos do Windows, permitindo-lhe efectivamente resolver tanto os caminhos do Linux como os caminhos do Windows à sua vontade.
Conclusion
Com PowerShell e WSL, podemos integrar comandos Linux no Windows, tal como se fossem aplicações nativas. Não há necessidade de caçar para os buildds Win32 de utilitários Linux ou ser forçado a interromper o seu fluxo de trabalho para cair dentro de uma shell Linux. Basta instalar a WSL, configurar o seu perfil PowerShell, e listar os comandos que pretende importar! O rico argumento aqui apresentado de opções de comandos e caminhos de ficheiros Linux e Windows é uma experiência que mesmo os comandos nativos do Windows não fornecem hoje.
O código fonte completo descrito acima, bem como orientações adicionais para o incorporar no seu fluxo de trabalho, está disponível em https://github.com/mikebattista/PowerShell-WSL-Interop.
Quais os comandos Linux que lhe parecem mais úteis? Que outras partes do seu fluxo de trabalho de desenvolvimento acha que faltam no Windows?
p>Deixe-nos saber nos comentários abaixo ou acima no GitHub!
Mike Battista
Gestor de Programa Sénior, Windows Developer Platform
Follow