Avatar

Mike

September 26, 2019

Eine häufige Frage von Windows-Entwicklern ist „Warum hat Windows noch kein <INSERT FAVORITE LINUX COMMAND HERE>?“. Ob man sich nach einem leistungsfähigen Pager wie less sehnt oder bekannte Befehle wie grep oder sed nutzen möchte, Windows-Entwickler wünschen sich einen einfachen Zugriff auf diese Befehle als Teil ihres Kern-Workflows.

Das Windows Subsystem für Linux (WSL) war hier ein großer Schritt nach vorne und ermöglichte es Entwicklern, Linux-Befehle von Windows aus aufzurufen, indem sie durch wsl.exe (z.B. wsl ls) vermittelt wurden. Das ist zwar eine deutliche Verbesserung, aber das Erlebnis ist in mehrfacher Hinsicht mangelhaft:

  • Befehle mit wsl voranzustellen ist mühsam und unnatürlich
  • Windows-Pfade, die als Argumente übergeben werden, werden oft nicht aufgelöst, da Backslashes als Escape-Zeichen und nicht als Verzeichnistrennzeichen interpretiert werden
  • Windows-Pfade, die als Argumente übergeben werden, werden oft nicht aufgelöst, da sie nicht in den entsprechenden Einhängepunkt in WSL übersetzt werden
  • Standardparameter, die in WSL-Anmeldeprofilen mit Aliasen und Umgebungsvariablen definiert sind, werden nicht beachtet
  • Linux-Pfadvervollständigung wird nicht unterstützt
  • Befehlsvervollständigung wird nicht unterstützt
  • Argumentenvervollständigung wird nicht unterstützt

Das Ergebnis dieser Unzulänglichkeiten ist, dass sich Linux-Befehle im Vergleich zu Windows wie Bürger zweiter Klasse anfühlen und schwieriger sind.Klasse gegenüber Windows fühlen und schwieriger zu bedienen sind, als sie sein sollten. Damit sich ein Befehl wie ein nativer Windows-Befehl anfühlt, müssen wir diese Probleme angehen.

PowerShell-Funktions-Wrapper

Wir können die Notwendigkeit beseitigen, Befehle mit wsl voranzustellen, die Übersetzung von Windows-Pfaden in WSL-Pfade handhaben und die Befehlsvervollständigung mit PowerShell-Funktions-Wrappern unterstützen. Die Grundanforderungen an die Wrapper sind:

  • Es sollte einen Funktions-Wrapper pro Linux-Befehl mit demselben Namen wie der Befehl geben
  • Der Wrapper sollte Windows-Pfade erkennen, die als Argumente übergeben werden, und sie in WSL-Pfade übersetzen
  • Der Wrapper sollte wsl mit dem entsprechenden Linux-Befehl aufrufen, alle Pipeline-Eingaben und alle an die Funktion übergebenen Kommandozeilenargumente weitergeben

Da diese Vorlage auf jeden Befehl angewendet werden kann, können wir die Definition dieser Wrapper abstrahieren und sie dynamisch aus einer Liste von zu importierenden Befehlen erzeugen.

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

Die $command Liste definiert die zu importierenden Befehle. Dann generieren wir dynamisch den Funktions-Wrapper für jeden mit dem Befehl Invoke-Expression (zuerst entfernen wir alle Aliase, die mit der Funktion in Konflikt stehen würden).

Die Funktion durchläuft die Befehlszeilenargumente, identifiziert Windows-Pfade mit den Befehlen Split-Path und Test-Path und konvertiert diese Pfade dann in WSL-Pfade. Wir lassen die Pfade durch eine Hilfsfunktion laufen, die wir später definieren werden und die Format-WslArgument heißt und Sonderzeichen wie Leerzeichen und Klammern umgeht, die sonst fehlinterpretiert werden würden.

Schließlich leiten wir die Pipeline-Eingabe und alle Befehlszeilenargumente an wsl weiter.

Mit diesen Funktions-Wrappern können wir nun unsere Lieblings-Linux-Befehle auf natürlichere Weise aufrufen, ohne sie mit wsl voranzustellen oder uns Gedanken darüber zu machen, wie Windows-Pfade in WSL-Pfade übersetzt werden:

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

Ein Starterset von Befehlen ist hier zu sehen, aber Sie können einen Wrapper für jeden beliebigen Linux-Befehl erzeugen, indem Sie ihn einfach zur Liste hinzufügen. Wenn Sie diesen Code zu Ihrem PowerShell-Profil hinzufügen, stehen Ihnen diese Befehle in jeder PowerShell-Sitzung genau wie native Befehle zur Verfügung!

Standardparameter

Es ist unter Linux üblich, Aliase und/oder Umgebungsvariablen innerhalb von Anmeldeprofilen zu definieren, um Standardparameter für häufig verwendete Befehle festzulegen (z. B. alias ls=ls -AFh oder export LESS=-i). Einer der Nachteile des Proxys durch eine nicht-interaktive Shell über wsl.exe ist, dass Login-Profile nicht geladen werden, so dass diese Standard-Parameter nicht verfügbar sind (d.h. ls innerhalb der WSL und wsl ls würden sich mit dem oben definierten Alias anders verhalten).

PowerShell bietet mit $PSDefaultParameterValues einen Standardmechanismus, um Standardparameterwerte zu definieren, allerdings nur für Cmdlets und erweiterte Funktionen. Die Umwandlung unserer Funktions-Wrapper in erweiterte Funktionen ist möglich, führt aber zu Komplikationen (z. B. stimmt PowerShell mit partiellen Parameternamen überein (z. B. -a für -ArgumentList), was zu Konflikten mit Linux-Befehlen führt, die partielle Namen als Argumente akzeptieren), und die Syntax zum Definieren von Standardwerten wäre für dieses Szenario nicht ideal (der Name eines Parameters muss im Schlüssel zum Definieren der Standardargumente enthalten sein, nicht nur der Befehlsname).

Mit einer kleinen Änderung an unseren Funktions-Wrappern können wir ein Modell ähnlich wie $PSDefaultParameterValues einführen und Standardparameter für Linux-Befehle aktivieren!

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

Indem wir $WslDefaultParameterValues in die Befehlszeile einfügen, die wir über wsl.exe senden, können Sie nun Anweisungen wie die folgenden in Ihr PowerShell-Profil aufnehmen, um Standardparameter zu konfigurieren!

$WslDefaultParameterValues = "-E"$WslDefaultParameterValues = "-i"$WslDefaultParameterValues = "-AFh --group-directories-first" 

Da dies nach $PSDefaultParameterValues modelliert ist, können Sie sie vorübergehend einfach deaktivieren, indem Sie den Schlüssel "Disabled" auf $true setzen. Eine separate Hashtabelle hat den zusätzlichen Vorteil, dass Sie $WslDefaultParameterValues getrennt von $PSDefaultParameterValues deaktivieren können.

Argumentenvervollständigung

In der PowerShell können Sie mit dem Befehl Register-ArgumentCompleter Argumentenvervollständigungen registrieren. Die Bash verfügt über leistungsfähige programmierbare Vervollständigungsmöglichkeiten. Mit WSL können Sie von der PowerShell aus in die Bash aufrufen. Wenn wir Argumentvervollständigungen für unsere PowerShell-Funktionswrapper registrieren und die Bash aufrufen können, um die Vervollständigungen zu generieren, können wir reichhaltige Argumentvervollständigungen mit der gleichen Treue wie in der Bash selbst erhalten!

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

Der Code ist ohne ein Verständnis einiger Bash-Interna ein wenig dicht, aber im Grunde genommen:

  • Wir registrieren den Argument-Completer für alle unsere Funktions-Wrapper, indem wir die $commands-Liste an den -CommandName-Parameter von Register-ArgumentCompleter
  • Wir bilden jeden Befehl auf die Shell-Funktion, die die Bash zur Vervollständigung verwendet ($F, die nach complete -F <FUNCTION> benannt ist, das zur Definition von Vervollständigungsspezifikationen in der Bash verwendet wird)
  • Wir konvertieren das $wordToComplete der PowerShell, $commandAst und $cursorPosition Argumente in das von den Bash-Vervollständigungsfunktionen erwartete Format gemäß der programmierbaren Bash-Vervollständigungsspezifikation
  • Wir erstellen eine Befehlszeile, die wir an wsl.exe übergeben können, um sicherzustellen, dass die Vervollständigungsumgebung korrekt eingerichtet ist, die entsprechende Vervollständigungsfunktion aufruft und dann einen String ausgibt, der die Vervollständigungsergebnisse durch neue Zeilen getrennt enthält
  • Wir rufen dann wsl mit der Befehlszeile auf, teilen den Ausgabestring am neuen Zeilentrenner, generieren dann CompletionResults für jeden, sortieren sie und escapen Zeichen wie Leerzeichen und Klammern, die sonst fehlinterpretiert würden

Das Endergebnis davon ist, dass unsere Linux-Befehls-Wrapper jetzt genau die gleiche Vervollständigung verwenden, die auch die Bash verwendet! Zum Beispiel:

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

Jede Vervollständigung liefert Werte, die für das Argument vor ihr spezifisch sind, und liest Konfigurationsdaten wie bekannte Hosts aus der WSL ein!

<TAB> geht die Optionen durch. <Ctrl + Space> zeigt alle verfügbaren Optionen an.

Zusätzlich können Sie, da die Bash-Vervollständigung jetzt zuständig ist, Linux-Pfade direkt in der PowerShell auflösen!

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

In Fällen, in denen die Bash-Vervollständigung keine Ergebnisse zurückgibt, PowerShell greift auf die Standardvervollständigung zurück, die Windows-Pfade auflöst, wodurch Sie effektiv in der Lage sind, sowohl Linux-Pfade als auch Windows-Pfade nach Belieben aufzulösen.

Fazit

Mit PowerShell und WSL können wir Linux-Befehle in Windows so integrieren, als wären es native Anwendungen. Es ist nicht nötig, nach Win32-Builds von Linux-Dienstprogrammen zu suchen oder den Arbeitsablauf zu unterbrechen, um in eine Linux-Shell zu wechseln. Installieren Sie einfach WSL, richten Sie Ihr PowerShell-Profil ein und listen Sie die Befehle auf, die Sie importieren möchten! Die hier gezeigte reichhaltige Argumentvervollständigung von Befehlsoptionen und Linux- und Windows-Dateipfaden ist eine Erfahrung, die selbst native Windows-Befehle heute nicht bieten.

Der vollständige, oben beschriebene Quellcode sowie zusätzliche Anleitungen zum Einbinden in Ihren Arbeitsablauf sind unter https://github.com/mikebattista/PowerShell-WSL-Interop verfügbar.

Welche Linux-Befehle finden Sie am nützlichsten? Welche anderen Teile Ihres Entwickler-Workflows vermissen Sie unter Windows?

Lassen Sie es uns in den Kommentaren unten oder drüben auf GitHub wissen!

Avatar
Mike Battista

Senior Program Manager, Windows Developer Platform

Folgen

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.