Типичный вопрос разработчиков под Windows: «Почему здесь до сих пор нет <ВСТАВЬТЕ ТУТ ЛЮБИМУЮ КОМАНДУ LINUX>
?». Будь то мощное пролистывание less
или привычные инструменты grep
или sed
, разработчики под Windows хотят получить лёгкий доступ к этим командам в повседневной работе.
Подсистема Windows для Linux (WSL) сделала огромный шаг вперёд в этом отношении. Она позволяет вызывать команды Linux из Windows, проксируя их через wsl.exe
(например, wsl ls
). Хотя это значительное улучшение, но такой вариант страдает от ряда недостатков.
- Повсеместное добавление
wsl
утомительно и неестественно. - Пути Windows в аргументах не всегда срабатывают, потому что обратные слэши интерпретируются как escape-символы, а не разделители каталогов.
- Пути Windows в аргументах не переводятся в соответствующую точку монтирования в WSL.
- Не учитываются параметры по умолчанию в профилях WSL с алиасами и переменными окружения.
- Не поддерживается завершение путей Linux.
- Не поддерживается завершение команд.
- Не поддерживается завершение аргументов.
В результате команды Linux воспринимаются под Windows как граждане второго сорта — и их сложнее использовать, чем родные команды. Чтобы уравнять их в правах, нужно решить перечисленные проблемы.
Оболочки функций PowerShell
C помощью оболочек функций PowerShell vы можем добавить автозавершение команд и устранить необходимость в префиксах wsl
, транслируя пути Windows в пути WSL. Основные требования к оболочкам:
- Для каждой команды Linux должна быть одна оболочка функции с тем же именем.
- Оболочка должна распознавать пути Windows, переданные в качестве аргументов, и преобразовывать их в пути WSL.
- Оболочка должна вызывать
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, просто добавив её в список. Если вы добавите этот код в свой профиль PowerShell, эти команды будут доступны вам в каждом сеансе PowerShell, как и нативные команды!
Параметры по умолчанию
В Linux принято определять алиасы и/или переменные окружения в профилях (login profile), задавая параметры по умолчанию для часто используемых команд (например, alias ls=ls -AFh
или export LESS=-i
). Один из недостатков проксирования через неинтерактивную оболочку wsl.exe
— то, что профили не загружаются, поэтому эти параметры по умолчанию недоступны (т. е. ls
в WSL и wsl ls
будут вести себя по-разному с алиасом, определённым выше).
PowerShell предоставляет $PSDefaultParameterValues, стандартный механизм для определения параметров по умолчанию, но только для командлетов и расширенных функций. Конечно, можно из наших оболочек сделать расширенные функции, но это вносит лишние осложнения (так, PowerShell соотносит частичные имена параметров (например, -a
соотносится с -ArgumentList
), которые будут конфликтовать с командами Linux, принимающими частичные имена в качестве аргументов), а синтаксис для определения значений по умолчанию будет не самым подходящим (для определения аргументов по умолчанию требуется имя параметра в ключе, а не только имя команды).
Однако с небольшим изменением наших оболочек мы можем внедрить модель, аналогичную $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 позволяет вызывать bash из PowerShell. Если мы можем зарегистрировать завершители аргументов для наших оболочек функций 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
в параметр-CommandName
дляRegister-ArgumentCompleter
. - Сопоставляем каждую команду с функцией оболочки, которую использует bash для автозавершения (для определения спецификаций автозавершения в bash используется
$F
, сокращение отcomplete -F <FUNCTION>
). - Преобразуем аргументы PowerShell
$wordToComplete
,$commandAst
и$cursorPosition
в формат, ожидаемый функциями автозавершения bash в соответствии со спецификациями программируемого автозавершения bash. - Составляем командную строку для передачи в
wsl.exe
, который обеспечивает правильную настройку среды, вызывает соответствующую функцию автозавершения и выводит результаты с разбиением по строкам. - Затем вызываем
wsl
с командной строкой, разделяем выдачу разделителями строк и генерируем для каждойCompletionResults
, сортируя их и экранируя символы, такие как пробелы и скобки, которые в противном случае были бы неверно истолкованы.
В итоге наши оболочки команд Linux будут использовать точно такое же автозавершение, как в bash! Например:
ssh -c <TAB> -J <TAB> -m <TAB> -O <TAB> -o <TAB> -Q <TAB> -w <TAB> -b <TAB>
Каждое автозавершение подоставляет значения, специфичные для предыдущего аргумента, считывая данные конфигурации, такие как известные хосты, из WSL!
<TAB>
будет циклически перебирать параметры. <Ctrl + пробел>
покажет все доступные опции.
Кроме того, поскольку теперь у нас работает автозавершение bash, вы можете автозавершать пути Linux непосредственно в PowerShell!
less /etc/<TAB>
ls /usr/share/<TAB>
vim ~/.bash<TAB>
В тех случаях, когда автозавершение bash не даёт никаких результатов, PowerShell возвращается к системе по умолчанию с путями Windows. Таким образом, вы на практике можете одновременно использовать и те, и другие пути на своё усмотрение.
Заключение
С помощью PowerShell и WSL мы можем интегрировать команды Linux в Windows как нативные приложения. Нет необходимости искать билды Win32 или утилиты Linux или прерывать рабочий процесс, переходя в Linux-шелл. Просто установите WSL, настройте профиль PowerShell и перечислите команды, которые хотите импортировать! Богатое автозавершение для параметров команд и путей к файлам Linux и Windows — это функциональность, которой сегодня нет даже в нативных командах Windows.
Полный исходный код, описанный выше, а также дополнительные рекомендации по его включению в рабочий процесс доступны здесь.
Какие команды Linux вы считаете наиболее полезными? Каких ещё привычных вещей не хватает при работе в Windows? Пишите в комментариях или на GitHub!
Автор: m1rko