completers/docker_completer/docker_completer.ps1
|
# docker tab completion for PowerShell # Help-driven completer for the Docker CLI, including nested subcommands and CLI plugins. Set-StrictMode -Version 2.0 function New-DockerCompletionResult { param( [string]$CompletionText, [string]$ResultType = 'ParameterValue', [string]$ToolTip = '', [string]$ListItemText = '' ) if ([string]::IsNullOrWhiteSpace($CompletionText)) { return $null } if ([string]::IsNullOrWhiteSpace($ListItemText)) { $ListItemText = $CompletionText } if ([string]::IsNullOrWhiteSpace($ToolTip)) { $ToolTip = $ListItemText } [System.Management.Automation.CompletionResult]::new( $CompletionText, $ListItemText, $ResultType, $ToolTip ) } function Get-DockerExecutablePath { foreach ($candidate in @('docker', 'docker.exe')) { $command = Get-Command -Name $candidate -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if ($null -ne $command) { return $command.Source } } return '' } function Get-DockerCatalogCache { $cache = Get-Variable -Name 'DockerCatalogCache' -Scope Script -ErrorAction Ignore if ($null -eq $cache -or $null -eq $cache.Value) { Set-Variable -Name 'DockerCatalogCache' -Value @{} -Scope Script } return (Get-Variable -Name 'DockerCatalogCache' -Scope Script).Value } function Get-DockerHelpText { param([string[]]$Segments) $commandPath = Get-DockerExecutablePath if ([string]::IsNullOrWhiteSpace($commandPath)) { return '' } $arguments = New-Object System.Collections.Generic.List[string] foreach ($segment in @($Segments)) { if (-not [string]::IsNullOrWhiteSpace($segment)) { [void]$arguments.Add($segment) } } [void]$arguments.Add('--help') # A CLI plugin that hangs must not hang the prompt: standard input is closed # immediately and the child is killed if it outlives the budget. $helpText = '' $process = [System.Diagnostics.Process]::new() try { $process.StartInfo = [System.Diagnostics.ProcessStartInfo]::new($commandPath) $process.StartInfo.UseShellExecute = $false $process.StartInfo.CreateNoWindow = $true $process.StartInfo.RedirectStandardInput = $true $process.StartInfo.RedirectStandardOutput = $true $process.StartInfo.RedirectStandardError = $true foreach ($argument in $arguments) { [void]$process.StartInfo.ArgumentList.Add($argument) } [void]$process.Start() $process.StandardInput.Close() $stdout = $process.StandardOutput.ReadToEndAsync() $stderr = $process.StandardError.ReadToEndAsync() if ($process.WaitForExit(4000)) { $helpText = ($stdout.GetAwaiter().GetResult() -replace '\e\[[0-9;?]*[ -/]*[@-~]', '') + "`n" + ($stderr.GetAwaiter().GetResult() -replace '\e\[[0-9;?]*[ -/]*[@-~]', '') } else { $process.Kill($true) } } catch { $helpText = '' } finally { $process.Dispose() } return $helpText } function Get-DockerOptionValueType { param([string]$Remainder) # " -f, --filter filter Filter output" - a single space, the type word, then # the aligned description column. A flag with no value has many spaces instead. if ($Remainder -match '^ (?<type>[a-z][A-Za-z0-9]*)(?:\s{2,}|$)') { return $Matches.type } return '' } function Get-DockerDescriptionEnumValue { param([string]$Description) $values = New-Object System.Collections.Generic.List[string] if ([string]::IsNullOrWhiteSpace($Description)) { return @() } # ("never"|"always"|"auto"), ("debug", "info", "warn"), (auto, tty, plain) foreach ($group in [regex]::Matches($Description, '\((?<alts>(?:"[^"]+"|[a-z][a-z0-9_.-]*)(?:\s*[|,]\s*(?:"[^"]+"|[a-z][a-z0-9_.-]*))+)\)')) { $alternatives = @($group.Groups['alts'].Value -split '\s*[|,]\s*') if ($alternatives.Count -lt 2) { continue } foreach ($alternative in $alternatives) { $value = $alternative.Trim().Trim('"') if ($value -match '^[A-Za-z][A-Za-z0-9_.-]*$' -and -not $values.Contains($value)) { [void]$values.Add($value) } } } # docker's --format documents its modes as "'table':" / "'json':". foreach ($group in [regex]::Matches($Description, "'(?<value>[a-z][a-z0-9_.-]*)':")) { $value = $group.Groups['value'].Value if (-not $values.Contains($value)) { [void]$values.Add($value) } } return @($values.ToArray()) } function Test-DockerPathOptionName { param([string]$Name) $bare = $Name -replace '^-+', '' return ($bare -match '(?i)(file|dir|directory|path|cert|cacert|key|config|output)$') } function ConvertFrom-DockerHelp { param([string]$HelpText) $lines = @([regex]::Split($HelpText, '\r?\n')) $options = New-Object System.Collections.Generic.List[object] $commands = New-Object System.Collections.Generic.List[object] $commandSeen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::Ordinal) $usageLines = New-Object System.Collections.Generic.List[string] $section = '' $current = $null foreach ($line in $lines) { $trimmed = $line.Trim() if ([string]::IsNullOrWhiteSpace($trimmed)) { $current = $null continue } # Section headers sit at column 0 or 1. Docker CLI plugins print them both # with and without the trailing colon, so the colon is optional here. if ($line -match '^\s{0,1}(?<name>[A-Za-z][A-Za-z ]*?)\s*:?\s*$' -and $trimmed -notmatch '^-') { $name = $Matches.name.Trim() $current = $null switch -Regex ($name) { '^(?:Options|Flags|Global Options|Global Flags|Advanced options)$' { $section = 'Options'; break } '^(?:\S+ )?Commands$' { $section = 'Commands'; break } '^Usage$' { $section = 'Usage'; break } default { $section = '' } } continue } if ($line -match '^Usage:?\s*(?<usage>\S.*)$') { [void]$usageLines.Add($Matches.usage) $section = '' $current = $null continue } $indent = $line.Length - $line.TrimStart(' ').Length if ($section -eq 'Usage') { [void]$usageLines.Add($trimmed) continue } if ($section -eq 'Commands') { if ($indent -ge 2 -and $indent -le 6 -and $line -match '^\s{2,}(?<name>[A-Za-z0-9][A-Za-z0-9_-]*)\*?(?:\s{2,}(?<description>\S.*))?$') { $name = $Matches.name $description = if ($Matches.ContainsKey('description')) { $Matches.description } else { '' } if ($commandSeen.Add($name)) { [void]$commands.Add([pscustomobject]@{ Name = $name; Description = $description }) } } continue } if ($section -ne 'Options') { continue } # Option rows start at column 2 or 6. Wrapped description lines are indented # to the description column, so they can never be mistaken for definitions. if ($indent -le 6 -and $trimmed.StartsWith('-')) { $names = New-Object System.Collections.Generic.List[string] foreach ($match in [regex]::Matches($trimmed, '(?<!\S)(--?[A-Za-z0-9][A-Za-z0-9-]*)(?=(?:\s|,|=|$))')) { $value = $match.Value if (-not $names.Contains($value)) { [void]$names.Add($value) } } if ($names.Count -eq 0) { $current = $null continue } $lastName = $names[$names.Count - 1] $remainder = $trimmed.Substring($trimmed.IndexOf($lastName) + $lastName.Length) $valueType = Get-DockerOptionValueType -Remainder $remainder $description = if ([string]::IsNullOrWhiteSpace($valueType)) { $remainder.Trim() } else { $remainder.Trim().Substring($valueType.Length).Trim() } $current = [pscustomobject]@{ Name = $lastName Names = @($names.ToArray()) TakesValue = (-not [string]::IsNullOrWhiteSpace($valueType)) ValueType = $valueType IsPathValue = (Test-DockerPathOptionName -Name $lastName) Description = $description } [void]$options.Add($current) continue } if ($null -ne $current) { $current.Description = ($current.Description + ' ' + $trimmed).Trim() } } $hasHelp = $false foreach ($option in $options) { if ($option.Names -contains '--help' -or $option.Names -contains '-h') { $hasHelp = $true break } } if (-not $hasHelp) { [void]$options.Add([pscustomobject]@{ Name = '--help' Names = @('-h', '--help') TakesValue = $false ValueType = '' IsPathValue = $false Description = 'Show help' }) } return [pscustomobject]@{ Commands = @($commands.ToArray()) Options = @($options.ToArray()) Operands = @(Get-DockerUsageOperand -UsageLines @($usageLines.ToArray())) } } function Get-DockerUsageOperand { param([string[]]$UsageLines) $operands = New-Object System.Collections.Generic.List[object] foreach ($usage in @($UsageLines)) { if ($usage -notmatch '(?i)^\s*docker(?:\.exe)?\b(?<rest>.*)$') { continue } foreach ($token in ($Matches.rest -split '\s+')) { $name = $token.Trim().Trim('[', ']', '|').TrimEnd('.') if ([string]::IsNullOrWhiteSpace($name)) { continue } if ($name -cmatch '^[A-Z][A-Z0-9_]*$' -and $name -notin @('OPTIONS', 'COMMAND', 'ARG', 'SUBCOMMAND')) { [void]$operands.Add([pscustomobject]@{ Name = $name IsPath = ($name -match '(?i)(PATH|URL|FILE|DIR|DIRECTORY)$') }) } } break } return @($operands.ToArray()) } function Get-DockerCommandCatalog { param([string[]]$Segments) $cache = Get-DockerCatalogCache $cacheKey = if (@($Segments).Count -gt 0) { @($Segments) -join ' ' } else { '<root>' } if ($cache.ContainsKey($cacheKey)) { $entry = $cache[$cacheKey] # A catalog that came back empty was most likely a transient failure (the # daemon or a plugin was not ready); retry it instead of poisoning the # whole session, but not on every keystroke. if ($entry.Complete -or $entry.Stopwatch.Elapsed.TotalSeconds -lt 30) { return $entry.Catalog } } $catalog = ConvertFrom-DockerHelp -HelpText (Get-DockerHelpText -Segments $Segments) $cache[$cacheKey] = [pscustomobject]@{ Catalog = $catalog Complete = (@($catalog.Commands).Count -gt 0 -or @($catalog.Options).Count -gt 1) Stopwatch = [System.Diagnostics.Stopwatch]::StartNew() } return $catalog } function Get-DockerCommandTokens { param( [System.Management.Automation.Language.CommandAst]$CommandAst, [int]$CursorPosition ) $tokens = New-Object System.Collections.Generic.List[string] if ($null -eq $CommandAst) { return @() } foreach ($element in @($CommandAst.CommandElements | Select-Object -Skip 1)) { if ($null -eq $element -or $null -eq $element.Extent) { continue } # Extent offsets and $cursorPosition are both absolute in the input line. # A token that ends at or after the cursor is the word being completed, # so it must not be consumed as a settled argument. if ($element.Extent.EndOffset -ge $CursorPosition) { continue } $text = $element.Extent.Text.Trim() if (-not [string]::IsNullOrWhiteSpace($text)) { [void]$tokens.Add($text) } } return $tokens.ToArray() } function Find-DockerOption { param( [object]$Catalog, [string]$Name ) if ([string]::IsNullOrWhiteSpace($Name)) { return $null } foreach ($option in @($Catalog.Options)) { foreach ($spelling in @($option.Names)) { if ([string]::Equals($spelling, $Name, [System.StringComparison]::Ordinal)) { return $option } } } return $null } function Get-DockerCompletionContext { param( [System.Management.Automation.Language.CommandAst]$CommandAst, [int]$CursorPosition ) if ($null -eq $CommandAst) { return $null } $elements = @($CommandAst.CommandElements) if ($elements.Count -lt 1) { return $null } $commandName = $elements[0].Extent.Text.Trim() if ($commandName -notin @('docker', 'docker.exe')) { return $null } $catalog = Get-DockerCommandCatalog -Segments @() $path = New-Object System.Collections.Generic.List[string] $tokens = @(Get-DockerCommandTokens -CommandAst $CommandAst -CursorPosition $CursorPosition) $operandCount = 0 $skipNext = $false foreach ($token in $tokens) { if ($skipNext) { $skipNext = $false continue } if ($token.StartsWith('-')) { if (-not $token.Contains('=')) { $option = Find-DockerOption -Catalog $catalog -Name $token if ($null -ne $option -and $option.TakesValue) { $skipNext = $true } } continue } $match = $null foreach ($command in @($catalog.Commands)) { if ([string]::Equals($command.Name, $token, [System.StringComparison]::Ordinal)) { $match = $command break } } if ($null -eq $match) { $operandCount++ continue } [void]$path.Add($token) $catalog = Get-DockerCommandCatalog -Segments @($path.ToArray()) } $previous = '' if ($tokens.Count -gt 0) { $previous = $tokens[$tokens.Count - 1] } return [pscustomobject]@{ CommandName = $commandName Tokens = $tokens Path = $path.ToArray() Catalog = $catalog Previous = $previous OperandCount = $operandCount } } function Test-DockerPathLikeWord { param([string]$Word) if ([string]::IsNullOrEmpty($Word)) { return $false } return ($Word -match '^[.~]|[\\/]|^[A-Za-z]:') } function Get-DockerOptionValueCompletion { param( [object]$Option, [string]$WordToComplete, [string]$Prefix ) $results = New-Object System.Collections.Generic.List[object] $values = @(Get-DockerDescriptionEnumValue -Description $Option.Description) $pattern = [System.Management.Automation.WildcardPattern]::Escape($WordToComplete) + '*' if ($values.Count -gt 0) { foreach ($value in $values) { if ($value -like $pattern) { [void]$results.Add((New-DockerCompletionResult -CompletionText ($Prefix + $value) -ResultType 'ParameterValue' -ToolTip $Option.Description -ListItemText $value)) } } return [object[]]$results } if ($Option.IsPathValue -or (Test-DockerPathLikeWord -Word $WordToComplete)) { if ([string]::IsNullOrEmpty($Prefix)) { return @([System.Management.Automation.CompletionCompleters]::CompleteFilename($WordToComplete)) } return @() } if ([string]::IsNullOrEmpty($WordToComplete)) { $placeholder = "<$($Option.ValueType)>" [void]$results.Add((New-DockerCompletionResult -CompletionText ($Prefix + $placeholder) -ResultType 'ParameterValue' -ToolTip $Option.Description -ListItemText $placeholder)) } return [object[]]$results } function Complete-DockerCommand { param( [string]$wordToComplete, [System.Management.Automation.Language.CommandAst]$commandAst, [int]$cursorPosition ) $context = Get-DockerCompletionContext -CommandAst $commandAst -CursorPosition $cursorPosition if ($null -eq $context) { return @() } $prefix = if ($null -eq $wordToComplete) { '' } else { $wordToComplete } $results = New-Object System.Collections.Generic.List[object] $pattern = [System.Management.Automation.WildcardPattern]::Escape($prefix) + '*' # Attached form: --log-level=de if ($prefix -match '^(?<name>--?[A-Za-z0-9][A-Za-z0-9-]*)=(?<value>.*)$') { $option = Find-DockerOption -Catalog $context.Catalog -Name $Matches.name if ($null -ne $option -and $option.TakesValue) { return @(Get-DockerOptionValueCompletion -Option $option -WordToComplete $Matches.value -Prefix "$($Matches.name)=") } return @() } if ($prefix -match '^-') { foreach ($option in @($context.Catalog.Options)) { foreach ($optionName in @($option.Names)) { if ($optionName -clike $pattern) { [void]$results.Add((New-DockerCompletionResult -CompletionText $optionName -ResultType 'ParameterName' -ToolTip $option.Description -ListItemText $optionName)) } } } return [object[]]$results } # Separate form: the settled token before the cursor is a value-bearing option. if ($context.Previous.StartsWith('-') -and -not $context.Previous.Contains('=')) { $option = Find-DockerOption -Catalog $context.Catalog -Name $context.Previous if ($null -ne $option -and $option.TakesValue) { return @(Get-DockerOptionValueCompletion -Option $option -WordToComplete $prefix -Prefix '') } } # A path-shaped word belongs to the engine's own filename completion. if (Test-DockerPathLikeWord -Word $prefix) { return @() } foreach ($command in @($context.Catalog.Commands)) { if ($command.Name -like $pattern) { $toolTip = if ([string]::IsNullOrWhiteSpace($command.Description)) { (@(@('docker') + @($context.Path) + @($command.Name)) -join ' ') } else { $command.Description } [void]$results.Add((New-DockerCompletionResult -CompletionText $command.Name -ResultType 'ParameterValue' -ToolTip $toolTip -ListItemText $command.Name)) } } # Operand slot. A path-shaped operand is left to the engine; anything else gets # a placeholder so the slot does not silently fill with unrelated file names. if ([string]::IsNullOrEmpty($prefix)) { $operands = @($context.Catalog.Operands) if ($context.OperandCount -lt $operands.Count) { $operand = $operands[$context.OperandCount] if (-not $operand.IsPath) { [void]$results.Add((New-DockerCompletionResult -CompletionText "<$($operand.Name)>" -ResultType 'ParameterValue' -ToolTip "$($operand.Name) operand" -ListItemText "<$($operand.Name)>")) } } } foreach ($option in @($context.Catalog.Options)) { foreach ($optionName in @($option.Names)) { if ($optionName -clike $pattern) { [void]$results.Add((New-DockerCompletionResult -CompletionText $optionName -ResultType 'ParameterName' -ToolTip $option.Description -ListItemText $optionName)) } } } return [object[]]$results } Register-ArgumentCompleter -Native -CommandName @('docker', 'docker.exe') -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) Complete-DockerCommand -wordToComplete $wordToComplete -commandAst $commandAst -cursorPosition $cursorPosition } |