completers/7z_completer/7z_completer.ps1
|
# 7z tab completion for PowerShell # Builds completion data from 7z built-in help. Set-StrictMode -Version 2.0 if (-not (Get-Variable -Name SevenZipCompletionCatalog -Scope Script -ErrorAction Ignore)) { $script:SevenZipCompletionCatalog = @{ Initialized = $false HelpCommand = $null Commands = @() CommandDescriptions = @{} SwitchTokens = @() SwitchDescriptions = @{} ValueHintsBySwitch = @{} ValueSwitches = @() PathLikeSwitches = @('-o', '-w') ArchiveTypeSwitches = @('-t', '-stx') MethodSwitches = @('-m', '-mx', '-mmt') ArchiveTypes = @() ArchiveTypesLoaded = $false } } function Resolve-SevenZipHelpCommand { if ($script:SevenZipCompletionCatalog.HelpCommand) { return $script:SevenZipCompletionCatalog.HelpCommand } $command = Get-Command -Name 7z.exe, 7z -ErrorAction Ignore | Select-Object -First 1 if ($command) { $script:SevenZipCompletionCatalog.HelpCommand = $command.Name } $script:SevenZipCompletionCatalog.HelpCommand } function Invoke-SevenZipHelpText { $commandName = Resolve-SevenZipHelpCommand if (-not $commandName) { return @() } try { @($null | & $commandName --help 2>$null | ForEach-Object { $_ -replace '\e\[[0-9;?]*[ -/]*[@-~]', '' }) } catch { @() } } function Get-SevenZipNormalizedHelpText { param([string[]]$Lines) if (-not $Lines -or $Lines.Count -eq 0) { return '' } $text = $Lines -join "`n" foreach ($marker in @('Usage:', '<Commands>', '<Switches>')) { $text = $text -replace [regex]::Escape($marker), ("`n" + $marker) } $text.Trim() } function Get-SevenZipHelpSectionLines { param( [string[]]$Lines, [string]$StartHeader, [string]$EndHeader ) $text = Get-SevenZipNormalizedHelpText -Lines $Lines if ([string]::IsNullOrWhiteSpace($text)) { return @() } $startPattern = [regex]::Escape($StartHeader) if ([string]::IsNullOrWhiteSpace($EndHeader)) { $pattern = "(?s)$startPattern\s*(.*)$" } else { $endPattern = [regex]::Escape($EndHeader) $pattern = "(?s)$startPattern\s*(.*?)(?=$endPattern)" } $match = [regex]::Match($text, $pattern) if (-not $match.Success) { return @() } @($match.Groups[1].Value -split '\r?\n' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } function Get-SevenZipCommandEntryList { param([string[]]$Lines) $commandLines = Get-SevenZipHelpSectionLines -Lines $Lines -StartHeader '<Commands>' -EndHeader '<Switches>' foreach ($line in $commandLines) { if ($line -match '^\s*([a-z][a-z0-9]*)\s*:\s*(.*)$') { @{ Token = $matches[1]; Description = $matches[2].Trim() } } } } function Get-SevenZipSwitchEntryList { param([string[]]$Lines) # A switch line is '<token><syntax> : <description>' where the syntax part is the # value grammar 7-Zip prints, e.g. '{o|e|p}{0|1|2}', '[r[-|0]][m[-|2]]{@listfile|!wildcard}'. $switchLines = Get-SevenZipHelpSectionLines -Lines $Lines -StartHeader '<Switches>' foreach ($line in $switchLines) { if ($line -match '^\s*(?<token>--|-[A-Za-z][A-Za-z0-9]*)(?<syntax>[\[{][^\s:]*)?\s*:\s*(?<description>.*)$') { @{ Token = $matches['token'] Syntax = if ($matches['syntax']) { $matches['syntax'] } else { '' } Description = $matches['description'].Trim() } } } } # --- Value syntax grammar ------------------------------------------------------------------- # # The syntax after a switch token is parsed into a tree of items: # @{ Kind = 'Literal'; Text = 'r' } # @{ Kind = 'Placeholder'; Name = 'Size'; Type = 'Numeric' | 'Text' | 'File' } # @{ Kind = 'Group'; Optional = $true/$false; Alternatives = @(@(items), @(items), ...) } # '{...}' is a required group, '[...]' an optional one, '|' separates alternatives, a lone # '{Word}' is a metavariable placeholder, 'N'/'#' are numeric placeholders and '@name'/'!name' # are a sigil followed by a file or wildcard placeholder. function ConvertTo-SevenZipSyntaxItemList { param([string]$LiteralRun) if ([string]::IsNullOrEmpty($LiteralRun)) { return @() } if ($LiteralRun -match '^(?<sigil>[@!])(?<name>[A-Za-z][A-Za-z0-9]*)$') { return @( @{ Kind = 'Literal'; Text = $matches['sigil'] } @{ Kind = 'Placeholder'; Name = $matches['name']; Type = 'File' } ) } if ($LiteralRun -eq 'N') { return @(@{ Kind = 'Placeholder'; Name = 'N'; Type = 'Numeric' }) } if ($LiteralRun -match '^(?<text>[^#]*)#$') { return @( (ConvertTo-SevenZipSyntaxItemList -LiteralRun $matches['text']) @{ Kind = 'Placeholder'; Name = '#'; Type = 'Numeric' } ) } if ($LiteralRun -cmatch '^[A-Z][A-Za-z]{3,}$') { $type = if ($LiteralRun -eq 'Size') { 'Numeric' } else { 'Text' } return @(@{ Kind = 'Placeholder'; Name = $LiteralRun; Type = $type }) } @(@{ Kind = 'Literal'; Text = $LiteralRun }) } function Read-SevenZipSyntaxSequence { param( [hashtable]$Reader, [string]$StopChars ) $items = New-Object System.Collections.Generic.List[object] $text = $Reader.Text while ($Reader.Position -lt $text.Length) { $char = $text[$Reader.Position] if ($StopChars.IndexOf($char) -ge 0) { break } if ($char -eq '{' -or $char -eq '[') { $group = Read-SevenZipSyntaxGroup -Reader $Reader if ($null -ne $group) { [void]$items.Add($group) } continue } $start = $Reader.Position while ($Reader.Position -lt $text.Length -and '{[|]}'.IndexOf($text[$Reader.Position]) -lt 0) { $Reader.Position++ } foreach ($item in @(ConvertTo-SevenZipSyntaxItemList -LiteralRun $text.Substring($start, $Reader.Position - $start))) { [void]$items.Add($item) } } @($items.ToArray()) } function Read-SevenZipSyntaxGroup { param([hashtable]$Reader) $text = $Reader.Text $open = $text[$Reader.Position] $optional = $open -eq '[' $close = if ($optional) { ']' } else { '}' } $Reader.Position++ $alternatives = New-Object System.Collections.Generic.List[object] while ($true) { $sequence = @(Read-SevenZipSyntaxSequence -Reader $Reader -StopChars '|]}') [void]$alternatives.Add($sequence) if ($Reader.Position -lt $text.Length -and $text[$Reader.Position] -eq '|') { $Reader.Position++ continue } break } if ($Reader.Position -lt $text.Length -and $text[$Reader.Position] -eq $close) { $Reader.Position++ } if ($alternatives.Count -eq 1) { $only = @($alternatives[0]) if ($only.Count -eq 1 -and $only[0].Kind -eq 'Literal') { $literal = [string]$only[0].Text # '[0-3]' is a numeric range; '{Word}' on its own is a metavariable, not a value. if ($literal -match '^(\d+)-(\d+)$' -and [int]$matches[1] -le [int]$matches[2]) { $alternatives.Clear() foreach ($number in ([int]$matches[1])..([int]$matches[2])) { [void]$alternatives.Add(@(@{ Kind = 'Literal'; Text = $number.ToString() })) } } elseif (-not $optional) { $type = if ($literal -eq 'Size') { 'Numeric' } else { 'Text' } return @{ Kind = 'Placeholder'; Name = $literal; Type = $type } } } } @{ Kind = 'Group'; Optional = $optional; Alternatives = @($alternatives.ToArray()) } } function ConvertFrom-SevenZipSwitchSyntax { param([string]$Syntax) if ([string]::IsNullOrWhiteSpace($Syntax)) { return @() } $reader = @{ Text = $Syntax; Position = 0 } @(Read-SevenZipSyntaxSequence -Reader $reader -StopChars '') } function Get-SevenZipSyntaxCandidateList { param( $Pending, [string]$Prefix, [string]$Typed, [bool]$Extended, [System.Collections.Generic.List[object]]$Sink ) # Walks the grammar against the typed value. Items matched by typed text are consumed; once # the typed text is exhausted the walk extends through literals and required groups and # emits a candidate when it stops (end of grammar, an optional group, or a placeholder). # Reaching the end of the grammar with the typed text fully consumed is a complete value too # (nothing at all for '-sns[-]', '-' for '-r[-|0]'), so it is emitted as well. # $Pending stays untyped: a typed [object[]] binder rejects the nested hashtable arrays. $pendingItems = @($Pending) if ($pendingItems.Count -eq 0) { if ([string]::IsNullOrEmpty($Typed)) { [void]$Sink.Add(@{ Kind = 'Value'; Text = $Prefix }) } return } $item = $pendingItems[0] $rest = @($pendingItems | Select-Object -Skip 1) switch ($item.Kind) { 'Literal' { $literal = [string]$item.Text if ($Typed.StartsWith($literal, [System.StringComparison]::OrdinalIgnoreCase)) { Get-SevenZipSyntaxCandidateList -Pending $rest -Prefix ($Prefix + $literal) -Typed $Typed.Substring($literal.Length) -Extended $Extended -Sink $Sink } elseif ($literal.StartsWith($Typed, [System.StringComparison]::OrdinalIgnoreCase)) { Get-SevenZipSyntaxCandidateList -Pending $rest -Prefix ($Prefix + $literal) -Typed '' -Extended $true -Sink $Sink } } 'Placeholder' { switch ($item.Type) { 'Numeric' { if ($Typed -match '^\d+') { Get-SevenZipSyntaxCandidateList -Pending $rest -Prefix ($Prefix + $matches[0]) -Typed $Typed.Substring($matches[0].Length) -Extended $Extended -Sink $Sink } elseif ($Extended -and [string]::IsNullOrEmpty($Typed)) { [void]$Sink.Add(@{ Kind = 'Value'; Text = $Prefix }) } } 'File' { if ($Extended -and [string]::IsNullOrEmpty($Typed)) { [void]$Sink.Add(@{ Kind = 'Value'; Text = ($Prefix + '<' + $item.Name + '>') }) } elseif (-not $Extended) { [void]$Sink.Add(@{ Kind = 'File'; Prefix = $Prefix; Typed = $Typed }) } } default { if ($Extended -and [string]::IsNullOrEmpty($Typed)) { [void]$Sink.Add(@{ Kind = 'Value'; Text = $Prefix }) } } } } 'Group' { if ($item.Optional -and $Extended) { if ([string]::IsNullOrEmpty($Typed)) { [void]$Sink.Add(@{ Kind = 'Value'; Text = $Prefix }) } return } foreach ($alternative in @($item.Alternatives)) { Get-SevenZipSyntaxCandidateList -Pending (@($alternative) + $rest) -Prefix $Prefix -Typed $Typed -Extended $Extended -Sink $Sink } if ($item.Optional) { Get-SevenZipSyntaxCandidateList -Pending $rest -Prefix $Prefix -Typed $Typed -Extended $Extended -Sink $Sink } } } } function Get-SevenZipArchiveTypeList { if ($script:SevenZipCompletionCatalog.ArchiveTypesLoaded) { return $script:SevenZipCompletionCatalog.ArchiveTypes } $script:SevenZipCompletionCatalog.ArchiveTypesLoaded = $true $commandName = Resolve-SevenZipHelpCommand if (-not $commandName) { return $script:SevenZipCompletionCatalog.ArchiveTypes } $raw = try { $null | & $commandName i 2>$null | ForEach-Object { $_ -replace '\e\[[0-9;?]*[ -/]*[@-~]', '' } } catch { @() } # The 'Formats:' table lists one archive format per line as # '<index> <flags> <Name> <extensions> <signature>'. The flag column can contain spaces, so # the name is the first single-word field that is neither the index nor the dotted flag string. $inFormats = $false $names = foreach ($line in @($raw)) { if ($line -match '^Formats:') { $inFormats = $true continue } if ($inFormats -and $line -match '^\S') { $inFormats = $false } if (-not $inFormats) { continue } foreach ($field in @($line -split '\s{2,}')) { $words = @($field.Trim() -split '\s+' | Where-Object { $_ }) if ($words.Count -eq 0) { continue } $first = $words[0] if ($first -match '^\d+$' -or $first -match '^[.]+$') { continue } if ($first -match '^[A-Za-z0-9][A-Za-z0-9]*$') { $first.ToLowerInvariant() break } } } $script:SevenZipCompletionCatalog.ArchiveTypes = @(@($names) | Sort-Object -Unique) $script:SevenZipCompletionCatalog.ArchiveTypes } function Initialize-SevenZipCompletionCatalog { if ($script:SevenZipCompletionCatalog.Initialized) { return } $helpLines = Invoke-SevenZipHelpText if ($helpLines -and $helpLines.Count -gt 0) { $commandDescriptions = @{} foreach ($entry in @(Get-SevenZipCommandEntryList -Lines $helpLines)) { $commandDescriptions[$entry.Token] = $entry.Description } $switchDescriptions = @{} $valueHints = @{} foreach ($entry in @(Get-SevenZipSwitchEntryList -Lines $helpLines)) { $switchDescriptions[$entry.Token] = $entry.Description $items = @(ConvertFrom-SevenZipSwitchSyntax -Syntax $entry.Syntax) if ($items.Count -gt 0) { $valueHints[$entry.Token.ToLowerInvariant()] = $items } } $script:SevenZipCompletionCatalog.Commands = @($commandDescriptions.Keys | Sort-Object -Unique) $script:SevenZipCompletionCatalog.CommandDescriptions = $commandDescriptions $script:SevenZipCompletionCatalog.SwitchTokens = @($switchDescriptions.Keys | Sort-Object -Unique) $script:SevenZipCompletionCatalog.SwitchDescriptions = $switchDescriptions $script:SevenZipCompletionCatalog.ValueHintsBySwitch = $valueHints } # 7-Zip only accepts switch values attached to the switch ('-tzip', '-oC:\out'); there is no # space-separated and no '--switch=value' form, so only the inline shape is completed. $script:SevenZipCompletionCatalog.ValueSwitches = @( @( $script:SevenZipCompletionCatalog.ValueHintsBySwitch.Keys $script:SevenZipCompletionCatalog.PathLikeSwitches $script:SevenZipCompletionCatalog.ArchiveTypeSwitches $script:SevenZipCompletionCatalog.MethodSwitches ) | Sort-Object -Unique | Sort-Object Length -Descending ) $script:SevenZipCompletionCatalog.Initialized = $true } function Get-SevenZipDescription { param([string]$Token) foreach ($table in @($script:SevenZipCompletionCatalog.SwitchDescriptions, $script:SevenZipCompletionCatalog.CommandDescriptions)) { if ($table.ContainsKey($Token) -and -not [string]::IsNullOrWhiteSpace($table[$Token])) { return $table[$Token] } } $Token } function New-SevenZipCompletionResult { param( [string]$CompletionText, [string]$ResultType, [string]$ToolTip ) if ([string]::IsNullOrWhiteSpace($ToolTip)) { $ToolTip = $CompletionText } [System.Management.Automation.CompletionResult]::new( $CompletionText, $CompletionText, $ResultType, $ToolTip ) } function Get-SevenZipLineTokens { param( [string]$Line, [int]$CursorPosition ) # Tokenise the command text rather than CommandElements: PowerShell splits '-oC:\dir' into a # parameter '-oC:' plus an argument, which loses the switch. The token under the cursor is # the current word (its full text, even when the cursor sits inside it); text to the right of # it is ignored. # The command extent excludes trailing whitespace, so a cursor past its end is after a space. $safeCursor = [Math]::Max($CursorPosition, 0) $tokens = New-Object System.Collections.Generic.List[string] $current = '' $currentStart = $safeCursor $afterWhitespace = $true # A quoted run of either kind (even unterminated, typographic quotes included) stays inside # its word, as PowerShell reads it; so does a backtick-escaped character. $wordPattern = '(?:`.?|[^\s`"''\u2018-\u201E]|["\u201C-\u201E](?:`.?|[^`"\u201C-\u201E])*["\u201C-\u201E]?|[''\u2018-\u201B][^''\u2018-\u201B]*[''\u2018-\u201B]?)+' foreach ($match in [regex]::Matches($Line, $wordPattern)) { if ($match.Index -ge $safeCursor) { break } $end = $match.Index + $match.Length if ($end -ge $safeCursor) { $current = $match.Value $currentStart = $match.Index $afterWhitespace = $false break } [void]$tokens.Add($match.Value) } @{ Before = @($tokens.ToArray()) Current = $current CurrentStart = $currentStart AfterWhitespace = $afterWhitespace } } function Get-SevenZipReplacementStart { param( [string]$Line, [int]$CursorPosition ) # PowerShell replaces only its own token under the cursor, and it ends a parameter token at a # colon ('-oC:' then '\Users') and a word at a leading '@'. Right after such a colon it # inserts at the cursor instead. $parseTokens = $null $parseErrors = $null $null = [System.Management.Automation.Language.Parser]::ParseInput($Line, [ref]$parseTokens, [ref]$parseErrors) foreach ($token in $parseTokens) { $extent = $token.Extent if ($extent.StartOffset -lt $CursorPosition -and $CursorPosition -le $extent.EndOffset) { if ($token.Kind -eq [System.Management.Automation.Language.TokenKind]::Parameter -and $token.Text.EndsWith(':') -and $CursorPosition -eq $extent.EndOffset) { return $CursorPosition } return $extent.StartOffset } } $CursorPosition } function Test-SevenZipHasOptionTerminator { param([string[]]$TokensBeforeCurrent) foreach ($token in $TokensBeforeCurrent) { if ($token -eq '--') { return $true } } $false } function Get-SevenZipActiveCommand { param( [string[]]$Tokens, [string[]]$KnownCommands ) $known = @{} foreach ($command in $KnownCommands) { $known[$command.ToLowerInvariant()] = $command } foreach ($token in $Tokens) { if ($token -eq '--') { break } $lookup = $token.ToLowerInvariant() if ($known.ContainsKey($lookup)) { return $known[$lookup] } } $null } function Get-SevenZipInlineValueSwitch { param( [string]$Token, [string[]]$KnownValueSwitches ) if ([string]::IsNullOrWhiteSpace($Token) -or -not $Token.StartsWith('-') -or $Token -eq '--') { return $null } foreach ($switchToken in $KnownValueSwitches) { if ($Token.Length -ge $switchToken.Length -and $Token.Substring(0, $switchToken.Length).Equals($switchToken, [System.StringComparison]::OrdinalIgnoreCase)) { return $switchToken } } $null } function Get-SevenZipTypedQuote { # The quote character the user opened the word with ('' when bare); PowerShell reads the # typographic quotes as quotes too. param([string]$Value) if ($Value -match '^[''"\u2018-\u201E]') { return $Value.Substring(0, 1) } '' } function ConvertFrom-SevenZipTypedWord { # The value of typed text without its quotes and that quote style's escapes. param([string]$Value) $quote = Get-SevenZipTypedQuote -Value $Value if (-not $quote) { return $Value -replace '`(.)', '$1' } $body = $Value.Substring(1) if ($quote -match '[''\u2018-\u201B]') { $inner = ([regex]::Match($body, '^(?:[^''\u2018-\u201B]|[''\u2018-\u201B]{2})*')).Value return $inner -replace '([''\u2018-\u201B])[''\u2018-\u201B]', '$1' } $inner = ([regex]::Match($body, '^(?:`.?|[^`"\u201C-\u201E]|["\u201C-\u201E]{2})*')).Value ($inner -replace '(["\u201C-\u201E])["\u201C-\u201E]', '$1') -replace '`(.)', '$1' } function ConvertTo-SevenZipQuotedValue { # Renders a value as one PowerShell argument: bare when safe and no quote was typed, # otherwise in the typed quote style (single by default). Whitespace and argument-mode # metacharacters (the typographic quotes included) end, split or expand a bare word. Right # after switch text ('-o', '-i!') a '.' or '[' also ends PowerShell's parameter token, which # hands 7-Zip the switch and the path as two arguments. param( [string]$Value, [string]$Quote, [switch]$AfterSwitch ) if ([string]::IsNullOrEmpty($Value)) { return $Value } if (-not $Quote) { if (-not ($AfterSwitch -and $Value -match '[.\[]') -and $Value -notmatch '[\s{}();,|&<>''"`$@#\u2018-\u201E]') { return $Value } $Quote = "'" } if ($Quote -match '[''\u2018-\u201B]') { return $Quote + ($Value -replace '([''\u2018-\u201B])', '$1$1') + $Quote } $Quote + ($Value -replace '([`"$\u201C-\u201E])', '`$1') + $Quote } function ConvertFrom-SevenZipCompletedPath { # CompleteFilename answers with a path already quoted or escaped for PowerShell; the parser # recovers the text (it undoes every doubled quote, typographic ones included). A quoted # answer is also wildcard-escaped for a cmdlet's -Path ('tick``dir', 'br`[1`]'), which a # native command would receive literally. param([string]$CompletionText) $ast = [System.Management.Automation.Language.Parser]::ParseInput('x ' + $CompletionText, [ref]$null, [ref]$null) $command = $ast.Find({ param($node) $node -is [System.Management.Automation.Language.CommandAst] }, $true) if ($command -and $command.CommandElements.Count -eq 2 -and $command.CommandElements[1] -is [System.Management.Automation.Language.StringConstantExpressionAst]) { $constant = $command.CommandElements[1] if ($constant.StringConstantType -eq [System.Management.Automation.Language.StringConstantType]::BareWord) { return $constant.Value } return [System.Management.Automation.WildcardPattern]::Unescape($constant.Value) } $CompletionText } function Get-SevenZipDirectoryCompletions { param( [string]$InputPath, [string]$SwitchPrefix = '', [int]$KeptLength = 0 ) # PowerShell keeps the first $KeptLength characters of the value (it ends a parameter token # at a drive colon, as in '-oC:' then '\Users') and replaces the rest, which keeps the quote # the user opened it with. $keptText = $InputPath.Substring(0, [Math]::Min([Math]::Max($KeptLength, 0), $InputPath.Length)) $typedText = $InputPath.Substring($keptText.Length) $typedQuote = Get-SevenZipTypedQuote -Value $typedText $keptValue = ConvertFrom-SevenZipTypedWord -Value $keptText $cleanInput = $keptValue + (ConvertFrom-SevenZipTypedWord -Value $typedText) # After a kept drive colon the path is a word of its own; otherwise it continues the switch. $head = $SwitchPrefix + $keptText $afterSwitch = $head.StartsWith('-') -and -not $head.EndsWith(':') # A bare drive ('D:') means that drive's root, and a trailing separator means "list the # children of this directory"; Split-Path would hand either one back as the leaf. if ($cleanInput -match '^[A-Za-z]:$') { $cleanInput += [System.IO.Path]::DirectorySeparatorChar } if ([string]::IsNullOrWhiteSpace($cleanInput)) { $parent = '.' $leaf = '' } elseif ($cleanInput -match '[\\/]$') { $parent = $cleanInput $leaf = '' } else { $parent = Split-Path -Path $cleanInput -Parent if ([string]::IsNullOrWhiteSpace($parent)) { $parent = '.' } $leaf = Split-Path -Path $cleanInput -Leaf } $filter = if ([string]::IsNullOrWhiteSpace($leaf)) { '*' } else { "$leaf*" } Get-ChildItem -LiteralPath $parent -Filter $filter -Directory -ErrorAction Ignore | ForEach-Object { $path = if ($cleanInput -and -not [System.IO.Path]::IsPathRooted($cleanInput)) { if ($parent -eq '.') { $_.Name } else { Join-Path -Path $parent -ChildPath $_.Name } } else { $_.FullName } if (-not $path.EndsWith([System.IO.Path]::DirectorySeparatorChar)) { $path += [System.IO.Path]::DirectorySeparatorChar } if (-not $path.StartsWith($keptValue, [System.StringComparison]::OrdinalIgnoreCase)) { return } $completionText = $head + (ConvertTo-SevenZipQuotedValue -Value $path.Substring($keptValue.Length) -Quote $typedQuote -AfterSwitch:$afterSwitch) New-SevenZipCompletionResult -CompletionText $completionText -ResultType 'ParameterValue' -ToolTip $_.FullName } } function Get-SevenZipFileCompletionList { param( [string]$InputPath, [string]$Prefix = '', [int]$KeptLength = 0 ) # CompleteFilename quotes for a cmdlet's -Path, and its quotes would cover only the path, not # a prefix ('-i@', '-ir!') that must sit outside them so PowerShell hands 7-Zip one argument # with the switch text intact. So it gets the plain typed path, and its answer is unwrapped # and quoted here, once, in the quote typed. PowerShell keeps the first $KeptLength # characters of the path ('-i@C:' ends its parameter token) and replaces only the rest. $keptText = $InputPath.Substring(0, [Math]::Min([Math]::Max($KeptLength, 0), $InputPath.Length)) $typedText = $InputPath.Substring($keptText.Length) $typedQuote = Get-SevenZipTypedQuote -Value $typedText $keptValue = ConvertFrom-SevenZipTypedWord -Value $keptText $cleanInput = $keptValue + (ConvertFrom-SevenZipTypedWord -Value $typedText) $head = $Prefix + $keptText $afterSwitch = $head.StartsWith('-') -and -not $head.EndsWith(':') foreach ($item in @([System.Management.Automation.CompletionCompleters]::CompleteFilename($cleanInput))) { $path = ConvertFrom-SevenZipCompletedPath -CompletionText $item.CompletionText if (-not $path.StartsWith($keptValue, [System.StringComparison]::OrdinalIgnoreCase)) { continue } if ([string]::IsNullOrEmpty($Prefix)) { $completionText = $keptText + (ConvertTo-SevenZipQuotedValue -Value $path.Substring($keptValue.Length) -Quote $typedQuote -AfterSwitch:$afterSwitch) [System.Management.Automation.CompletionResult]::new($completionText, $item.ListItemText, $item.ResultType, $item.ToolTip) continue } $completionText = if ($Prefix -eq '@') { # A bare word cannot start with '@' (PowerShell reads a splat), so the '@' of a list # file goes inside the quotes. ConvertTo-SevenZipQuotedValue -Value ($Prefix + $path) -Quote $typedQuote } else { $head + (ConvertTo-SevenZipQuotedValue -Value $path.Substring($keptValue.Length) -Quote $typedQuote -AfterSwitch:$afterSwitch) } New-SevenZipCompletionResult -CompletionText $completionText -ResultType 'ParameterValue' -ToolTip $item.ToolTip } } function Get-SevenZipMethodValueCompletionList { param( [string]$SwitchToken, [string]$TypedValue ) # -m parameters are documented in the 7-Zip manual rather than in --help, so the # commonly used keys and their value sets are static. $onOff = @('on', 'off') $levels = @('0', '1', '3', '5', '7', '9') $threadCounts = @('off', 'on', '1', [Environment]::ProcessorCount.ToString()) $parameterValues = [ordered]@{ 'x' = @{ Values = $levels; ToolTip = 'Compression level (0 store ... 9 ultra)' } '0' = @{ Values = @('LZMA2', 'LZMA', 'PPMd', 'BZip2', 'Deflate', 'Deflate64', 'Copy'); ToolTip = 'Compression method for the main stream' } 's' = @{ Values = $onOff; ToolTip = 'Solid archive mode' } 'f' = @{ Values = $onOff; ToolTip = 'Executable filters (BCJ, ARM64 ...)' } 'hc' = @{ Values = $onOff; ToolTip = 'Header compression' } 'he' = @{ Values = $onOff; ToolTip = 'Header encryption' } 'mt' = @{ Values = $threadCounts; ToolTip = 'Multithreading: on, off or thread count' } 'd' = @{ Values = @('16m', '32m', '64m', '128m', '256m', '512m', '1g'); ToolTip = 'Dictionary size' } 'fb' = @{ Values = @('32', '64', '128', '273'); ToolTip = 'Fast bytes' } 'qs' = @{ Values = $onOff; ToolTip = 'Sort files by type in solid archives' } 'tm' = @{ Values = $onOff; ToolTip = 'Store last modified timestamps' } 'tc' = @{ Values = $onOff; ToolTip = 'Store creation timestamps' } 'ta' = @{ Values = $onOff; ToolTip = 'Store last access timestamps' } } # '-mx9' and '-mx=9' are both accepted; keep whatever separator was typed. $separator = '' $typed = $TypedValue if ($typed.StartsWith('=')) { $separator = '=' $typed = $typed.Substring(1) } $candidates = New-Object System.Collections.Generic.List[object] # '-mx[N]' and '-mmt[N]' are complete without a value; offer the bare switch first. if ($TypedValue -eq '' -and $SwitchToken -ne '-m') { [void]$candidates.Add(@{ Text = $SwitchToken; ToolTip = Get-SevenZipDescription -Token $SwitchToken }) } switch ($SwitchToken.ToLowerInvariant()) { '-mx' { foreach ($level in $levels) { [void]$candidates.Add(@{ Text = $SwitchToken + $separator + $level; ToolTip = 'Compression level: -mx1 (fastest) ... -mx9 (ultra)' }) } } '-mmt' { foreach ($count in $threadCounts) { [void]$candidates.Add(@{ Text = $SwitchToken + $separator + $count; ToolTip = 'Number of CPU threads' }) } } '-m' { $equalsIndex = $typed.IndexOf('=') if ($equalsIndex -lt 0) { foreach ($key in $parameterValues.Keys) { [void]$candidates.Add(@{ Text = $SwitchToken + $key + '='; ToolTip = $parameterValues[$key].ToolTip }) } } else { $key = $typed.Substring(0, $equalsIndex) if ($parameterValues.Contains($key)) { foreach ($value in $parameterValues[$key].Values) { [void]$candidates.Add(@{ Text = $SwitchToken + $key + '=' + $value; ToolTip = $parameterValues[$key].ToolTip }) } } } } } foreach ($candidate in $candidates.ToArray()) { if ($candidate.Text.StartsWith($SwitchToken + $TypedValue, [System.StringComparison]::OrdinalIgnoreCase)) { New-SevenZipCompletionResult -CompletionText $candidate.Text -ResultType 'ParameterValue' -ToolTip $candidate.ToolTip } } } function Get-SevenZipValueHintCompletionList { param( [string]$SwitchToken, [string]$TypedValue, [int]$KeptLength = 0 ) $switchKey = $SwitchToken.ToLowerInvariant() if (-not $script:SevenZipCompletionCatalog.ValueHintsBySwitch.ContainsKey($switchKey)) { return @() } $sink = New-Object System.Collections.Generic.List[object] $null = Get-SevenZipSyntaxCandidateList -Pending $script:SevenZipCompletionCatalog.ValueHintsBySwitch[$switchKey] -Prefix '' -Typed $TypedValue -Extended $false -Sink $sink # The value exactly as typed (the bare switch when nothing follows it) goes first, so Tab on a # finished '-sns' keeps it instead of inserting the longer, inverted '-sns-'. # 7-Zip's switch table gives '-u' a one-character minimum that its all-optional grammar # ('-u[-][p#]...') does not show: a bare '-u' fails with 'Too short switch'. The other # all-optional switches (-bb, -r, -scrc, -seml, -si, -sfx, -sns, -spf, -ssc, -w) parse bare. $bareRejected = $switchKey -eq '-u' $candidates = @($sink.ToArray() | Where-Object { -not ($bareRejected -and $_.Kind -eq 'Value' -and $_.Text -eq '') } | Sort-Object -Stable -Property { -not ($_.Kind -eq 'Value' -and [string]::Equals($_.Text, $TypedValue, [System.StringComparison]::OrdinalIgnoreCase)) }) $description = Get-SevenZipDescription -Token $SwitchToken $seen = @{} foreach ($candidate in $candidates) { if ($candidate.Kind -eq 'File') { $filePrefix = $SwitchToken + $candidate.Prefix foreach ($item in @(Get-SevenZipFileCompletionList -InputPath $candidate.Typed -Prefix $filePrefix -KeptLength ($KeptLength - $filePrefix.Length))) { if (-not $seen.ContainsKey($item.CompletionText)) { $seen[$item.CompletionText] = $true $item } } continue } $completionText = $SwitchToken + $candidate.Text if ($seen.ContainsKey($completionText)) { continue } $seen[$completionText] = $true New-SevenZipCompletionResult -CompletionText $completionText -ResultType 'ParameterValue' -ToolTip ($completionText + ' : ' + $description) } } function Get-SevenZipSwitchCompletionList { param([string]$CurrentWord) $script:SevenZipCompletionCatalog.SwitchTokens | Where-Object { [string]::IsNullOrEmpty($CurrentWord) -or $_.StartsWith($CurrentWord, [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { New-SevenZipCompletionResult -CompletionText $_ -ResultType 'ParameterName' -ToolTip (Get-SevenZipDescription -Token $_) } } function Get-SevenZipCommandCompletionList { param([string]$CurrentWord) $script:SevenZipCompletionCatalog.Commands | Where-Object { [string]::IsNullOrEmpty($CurrentWord) -or $_.StartsWith($CurrentWord, [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { New-SevenZipCompletionResult -CompletionText $_ -ResultType 'ParameterValue' -ToolTip (Get-SevenZipDescription -Token $_) } } function Get-SevenZipCompletionResultList { param( [string]$currentWord, [string[]]$tokensBeforeCurrent, [int]$keptLength ) $hasOptionTerminator = Test-SevenZipHasOptionTerminator -TokensBeforeCurrent $tokensBeforeCurrent $activeCommand = Get-SevenZipActiveCommand -Tokens $tokensBeforeCurrent -KnownCommands $script:SevenZipCompletionCatalog.Commands if (-not $hasOptionTerminator) { $inlineValueSwitch = Get-SevenZipInlineValueSwitch -Token $currentWord -KnownValueSwitches $script:SevenZipCompletionCatalog.ValueSwitches if ($inlineValueSwitch) { $typedValue = $currentWord.Substring($inlineValueSwitch.Length) $switchKey = $inlineValueSwitch.ToLowerInvariant() if ($script:SevenZipCompletionCatalog.PathLikeSwitches -contains $switchKey) { return Get-SevenZipDirectoryCompletions -InputPath $typedValue -SwitchPrefix $inlineValueSwitch -KeptLength ($keptLength - $inlineValueSwitch.Length) } if ($script:SevenZipCompletionCatalog.ArchiveTypeSwitches -contains $switchKey) { return Get-SevenZipArchiveTypeList | Where-Object { $_.StartsWith($typedValue, [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { New-SevenZipCompletionResult -CompletionText ($inlineValueSwitch + $_) -ResultType 'ParameterValue' -ToolTip "Archive type $_" } } $valueResults = New-Object System.Collections.Generic.List[object] if ($script:SevenZipCompletionCatalog.MethodSwitches -contains $switchKey) { foreach ($result in @(Get-SevenZipMethodValueCompletionList -SwitchToken $inlineValueSwitch -TypedValue $typedValue)) { [void]$valueResults.Add($result) } } else { foreach ($result in @(Get-SevenZipValueHintCompletionList -SwitchToken $inlineValueSwitch -TypedValue $typedValue -KeptLength $keptLength)) { [void]$valueResults.Add($result) } } # A switch whose documented value is only a metavariable ('-p{Password}') has nothing # to offer; keep completing the switch name itself instead of going silent. if ($valueResults.Count -gt 0 -or -not [string]::IsNullOrEmpty($typedValue)) { return $valueResults.ToArray() } } } if (-not $activeCommand) { if ([string]::IsNullOrWhiteSpace($currentWord)) { $commandResults = @(Get-SevenZipCommandCompletionList -CurrentWord '') $switchResults = if ($hasOptionTerminator) { @() } else { @(Get-SevenZipSwitchCompletionList -CurrentWord '') } return @($commandResults + $switchResults) } if (-not $hasOptionTerminator -and $currentWord.StartsWith('-')) { return Get-SevenZipSwitchCompletionList -CurrentWord $currentWord } if (-not $hasOptionTerminator -and $currentWord.StartsWith('@')) { return Get-SevenZipFileCompletionList -InputPath $currentWord.Substring(1) -Prefix '@' -KeptLength ($keptLength - 1) } return Get-SevenZipCommandCompletionList -CurrentWord $currentWord } if (-not $hasOptionTerminator -and $currentWord.StartsWith('-')) { return Get-SevenZipSwitchCompletionList -CurrentWord $currentWord } # Everything after the command is <archive_name> then <file_names>, so this is a path slot; # '@listfile' names a file holding the file list. if (-not $hasOptionTerminator -and $currentWord.StartsWith('@')) { return Get-SevenZipFileCompletionList -InputPath $currentWord.Substring(1) -Prefix '@' -KeptLength ($keptLength - 1) } $fileResults = @(Get-SevenZipFileCompletionList -InputPath $currentWord -KeptLength $keptLength) if (-not $hasOptionTerminator -and [string]::IsNullOrWhiteSpace($currentWord)) { $switchResults = @(Get-SevenZipSwitchCompletionList -CurrentWord '') return @($switchResults + $fileResults) } $fileResults } function Complete-SevenZip { param( [string]$wordToComplete, [System.Management.Automation.Language.CommandAst]$commandAst, [int]$cursorPosition ) Initialize-SevenZipCompletionCatalog # $cursorPosition indexes the whole input line; the command text is command-relative. $line = $commandAst.Extent.Text $relativeCursor = $cursorPosition - $commandAst.Extent.StartOffset $lineTokens = Get-SevenZipLineTokens -Line $line -CursorPosition $relativeCursor $tokensBeforeCurrent = @($lineTokens.Before | Select-Object -Skip 1) $currentWord = if ($lineTokens.AfterWhitespace) { '' } elseif (-not [string]::IsNullOrEmpty($lineTokens.Current)) { $lineTokens.Current } else { $wordToComplete } # PowerShell replaces only the tail of a word it splits ('-oC:' then '\Users\Tr'); that kept # head comes off every completion so it is not typed twice. $keptLength = 0 if (-not $lineTokens.AfterWhitespace) { $replacementStart = Get-SevenZipReplacementStart -Line $line -CursorPosition $relativeCursor $keptLength = [Math]::Min([Math]::Max($replacementStart - $lineTokens.CurrentStart, 0), $currentWord.Length) } $results = @(Get-SevenZipCompletionResultList -currentWord $currentWord -tokensBeforeCurrent $tokensBeforeCurrent -keptLength $keptLength) if ($keptLength -eq 0) { return $results } $keptText = $currentWord.Substring(0, $keptLength) foreach ($result in $results) { $text = $result.CompletionText if ($text.Length -gt $keptLength -and $text.StartsWith($keptText, [System.StringComparison]::OrdinalIgnoreCase)) { [System.Management.Automation.CompletionResult]::new($text.Substring($keptLength), $result.ListItemText, $result.ResultType, $result.ToolTip) } } } Register-ArgumentCompleter -Native -CommandName '7z', '7z.exe' -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) Complete-SevenZip -wordToComplete $wordToComplete -commandAst $commandAst -cursorPosition $cursorPosition } |