completers/xcopy_completer/xcopy_completer.ps1
|
# xcopy tab completion for PowerShell # Builds a help-driven switch catalog and path-aware value completion for xcopy. Set-StrictMode -Version 2.0 if (-not (Get-Variable -Name XcopyCompletionCatalog -Scope Script -ErrorAction Ignore)) { $script:XcopyCompletionCatalog = @{ Initialized = $false Options = @() OptionInfoByKey = @{} DateSuggestions = @() } } function Test-XcopyCommandAvailable { [bool](Get-Command -Name xcopy.exe -ErrorAction SilentlyContinue) } function Invoke-XcopyHelpText { if (-not (Test-XcopyCommandAvailable)) { return @() } try { @(& xcopy.exe '/?' 2>$null) } catch { @() } } function New-XcopyCompletionResult { param( [string]$CompletionText, [string]$ResultType, [string]$ToolTip, [string]$ListItemText ) if ([string]::IsNullOrWhiteSpace($ListItemText)) { $ListItemText = $CompletionText } if ([string]::IsNullOrWhiteSpace($ToolTip)) { $ToolTip = $CompletionText } [System.Management.Automation.CompletionResult]::new( $CompletionText, $ListItemText, $ResultType, $ToolTip ) } function Remove-XcopyOuterQuotes { param([string]$Value) if ($null -eq $Value) { return $Value } $Value.Trim([char[]]@([char]34, [char]39)) } function ConvertTo-XcopyQuotedValue { param( [string]$Value, [bool]$AlwaysQuote = $false ) if ([string]::IsNullOrWhiteSpace($Value)) { return $Value } if (($AlwaysQuote -or $Value -match '\s') -and -not ($Value.StartsWith('"') -and $Value.EndsWith('"'))) { $escaped = $Value.Replace('`', '``').Replace('"', '`"') return '"' + $escaped + '"' } $Value } function Get-XcopyStaticOptionMetadata { $today = Get-Date @{ '/d' = @{ Key = '/d' Display = '/D[:date]' CompletionText = '/D' Description = 'Copies files changed on or after the specified date.' InlineValueKind = 'List' Suggestions = @( $today.ToString('M-d-yyyy'), $today.ToString('MM-dd-yyyy'), (Get-Date -Date $today.AddDays(-1)).ToString('M-d-yyyy'), '1-1-2024', '01-01-2024' ) } '/exclude' = @{ Key = '/exclude' Display = '/EXCLUDE:file1[+file2][+file3]...' CompletionText = '/EXCLUDE:' Description = 'Specifies files containing exclude strings to match against absolute paths.' InlineValueKind = 'PathChain' } '/sparse' = @{ Key = '/sparse' Display = '/SPARSE' CompletionText = '/SPARSE' Description = 'Enable retaining the sparse state of files during copy.' } '/-sparse' = @{ Key = '/-sparse' Display = '/-SPARSE' CompletionText = '/-SPARSE' Description = 'Disable retaining the sparse state of files during copy.' } '/?' = @{ Key = '/?' Display = '/?' CompletionText = '/?' Description = 'Displays this help message.' } } } function Expand-XcopyHelpToken { param([string]$Token) $cleanToken = $Token.Trim().TrimEnd('.', ',', ';', ')') if ([string]::IsNullOrWhiteSpace($cleanToken)) { return @() } if ($cleanToken.Equals('/[-]SPARSE', [System.StringComparison]::OrdinalIgnoreCase)) { return @('/SPARSE', '/-SPARSE') } @($cleanToken) } function ConvertFrom-XcopyHelpToken { param([string]$Token) foreach ($expandedToken in (Expand-XcopyHelpToken -Token $Token)) { $match = [regex]::Match($expandedToken, '^/(?<name>-?[A-Za-z?][A-Za-z0-9-]*)(?<suffix>.*)$') if (-not $match.Success) { continue } $root = '/' + $match.Groups['name'].Value $suffix = $match.Groups['suffix'].Value $completionText = if ($suffix -match '^\[:') { $root } elseif ($suffix.StartsWith(':')) { "${root}:" } else { $root } @{ Key = $root.ToLowerInvariant() Display = $expandedToken CompletionText = $completionText Description = $expandedToken } } } function Get-XcopyOptionLineMatch { param([string]$Line) # Either '/TOKEN description' or a token alone on its line (help prints # '/EXCLUDE:file1[+file2][+file3]...' that way, with the description on the next lines). [regex]::Match($Line, '^\s*(?<token>/\S+)(?:\s{2,}(?<description>.+?))?\s*$') } function Initialize-XcopyCompletionCatalog { if ($script:XcopyCompletionCatalog.Initialized) { return } $catalog = @{} foreach ($entry in (Get-XcopyStaticOptionMetadata).GetEnumerator()) { $catalog[$entry.Key] = @{} + $entry.Value } $helpLines = Invoke-XcopyHelpText $currentKeys = @() foreach ($line in $helpLines) { $optionMatch = Get-XcopyOptionLineMatch -Line $line if ($optionMatch.Success) { $description = $optionMatch.Groups['description'].Value.Trim() $currentKeys = @() foreach ($parsed in @(ConvertFrom-XcopyHelpToken -Token $optionMatch.Groups['token'].Value)) { $key = $parsed.Key if ($catalog.ContainsKey($key)) { if (-not $catalog[$key].ContainsKey('Display')) { $catalog[$key]['Display'] = $parsed.Display } if (-not $catalog[$key].ContainsKey('CompletionText')) { $catalog[$key]['CompletionText'] = $parsed.CompletionText } } else { $catalog[$key] = @{} + $parsed } $catalog[$key]['Description'] = $description $currentKeys += $key } continue } if ($currentKeys.Count -gt 0 -and $line -match '^\s{2,}(?<continuation>\S.*)$') { $continuation = $matches['continuation'].Trim() foreach ($key in $currentKeys) { if ([string]::IsNullOrWhiteSpace($continuation)) { continue } if ([string]::IsNullOrEmpty($catalog[$key]['Description'])) { $catalog[$key]['Description'] = $continuation } elseif (-not $catalog[$key]['Description'].EndsWith($continuation, [System.StringComparison]::OrdinalIgnoreCase)) { $catalog[$key]['Description'] += ' ' + $continuation } } continue } $currentKeys = @() } $script:XcopyCompletionCatalog.Options = @( foreach ($entry in $catalog.Values) { [pscustomobject]$entry } ) | Sort-Object -Property CompletionText, Display -Unique $script:XcopyCompletionCatalog.OptionInfoByKey = @{} foreach ($option in $script:XcopyCompletionCatalog.Options) { $script:XcopyCompletionCatalog.OptionInfoByKey[$option.Key] = $option } $dateSuggestions = New-Object System.Collections.Generic.List[string] foreach ($option in $script:XcopyCompletionCatalog.Options) { if ($option.Key -eq '/d' -and $option.PSObject.Properties.Name -contains 'Suggestions') { foreach ($suggestion in $option.Suggestions) { if (-not [string]::IsNullOrWhiteSpace($suggestion)) { $dateSuggestions.Add([string]$suggestion) } } } } $script:XcopyCompletionCatalog.DateSuggestions = @($dateSuggestions | Sort-Object -Unique) $script:XcopyCompletionCatalog.Initialized = $true } function Get-XcopyCurrentToken { param( [string]$Line, [int]$CursorPosition, [string]$Fallback ) if ([string]::IsNullOrWhiteSpace($Line)) { return $Fallback } $safeCursor = [Math]::Min([Math]::Max($CursorPosition, 0), $Line.Length) $prefix = $Line.Substring(0, $safeCursor) if ($prefix -match '\s$') { return '' } $parts = @([regex]::Matches($prefix, '"[^"]*"|\S+') | ForEach-Object { $_.Value }) if ($parts.Count -gt 0) { return $parts[-1] } $Fallback } function Get-XcopyArgumentList { param( [System.Management.Automation.Language.CommandAst]$CommandAst, [int]$CursorPosition ) $arguments = @() foreach ($element in $CommandAst.CommandElements | Select-Object -Skip 1) { if ($element.Extent.EndOffset -lt $CursorPosition) { $arguments += $element.Extent.Text } } $arguments } function Get-XcopyOptionKey { param([string]$Token) $cleanToken = Remove-XcopyOuterQuotes $Token if ([string]::IsNullOrWhiteSpace($cleanToken) -or -not $cleanToken.StartsWith('/')) { return $null } $match = [regex]::Match($cleanToken, '^/(?<name>-?[A-Za-z?][A-Za-z0-9-]*)') if ($match.Success) { return ('/' + $match.Groups['name'].Value).ToLowerInvariant() } $null } function Get-XcopyCompletionContext { param([string[]]$Arguments) $positionals = New-Object System.Collections.Generic.List[string] foreach ($argument in $Arguments) { if ([string]::IsNullOrWhiteSpace($argument)) { continue } if ($null -ne (Get-XcopyOptionKey -Token $argument)) { continue } $positionals.Add($argument) } [pscustomobject]@{ Positionals = @($positionals) } } function Get-XcopyUniqueCompletions { param([System.Management.Automation.CompletionResult[]]$Results) $seen = @{} $unique = [System.Collections.Generic.List[System.Management.Automation.CompletionResult]]::new() foreach ($result in $Results) { if ($null -eq $result) { continue } if ($seen.ContainsKey($result.CompletionText)) { continue } $seen[$result.CompletionText] = $true $unique.Add($result) } @($unique.ToArray()) } function Get-XcopyPathCompletions { param( [string]$InputPath, [string]$Kind, [string]$CompletionPrefix = '' ) $cleanInput = Remove-XcopyOuterQuotes $InputPath $alwaysQuote = -not [string]::IsNullOrEmpty($InputPath) -and ($InputPath.StartsWith('"') -or $InputPath.StartsWith("'")) 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 } $inputIsRooted = -not [string]::IsNullOrWhiteSpace($cleanInput) -and [System.IO.Path]::IsPathRooted($cleanInput) # Filter during enumeration and cap the result: a completion menu past a few hundred # entries is unusable, and %TEMP%-sized directories (30k entries) must not take seconds. $maxResults = 500 $items = @( Get-ChildItem -LiteralPath $parent -Filter ($leaf + '*') -ErrorAction Ignore | Where-Object { if ($Kind -eq 'Directory') { $_.PSIsContainer } elseif ($Kind -eq 'File') { -not $_.PSIsContainer } else { $true } } | Select-Object -First $maxResults ) foreach ($item in $items | Sort-Object -Property Name) { if ($inputIsRooted) { $pathText = Join-Path -Path $parent -ChildPath $item.Name } elseif ($parent -eq '.' -or [string]::IsNullOrWhiteSpace($cleanInput)) { $pathText = $item.Name } else { $pathText = Join-Path -Path $parent -ChildPath $item.Name } if ($item.PSIsContainer -and -not $pathText.EndsWith('\')) { $pathText += '\' } $quotedPath = ConvertTo-XcopyQuotedValue -Value $pathText -AlwaysQuote $alwaysQuote $completionText = $CompletionPrefix + $quotedPath $listItemText = $CompletionPrefix + $pathText New-XcopyCompletionResult ` -CompletionText $completionText ` -ListItemText $listItemText ` -ResultType 'ParameterValue' ` -ToolTip $item.FullName } } function Get-XcopyPrefixedSuggestions { param( [string]$Prefix, [string]$CurrentValue, [string[]]$Suggestions, [string]$ToolTip ) $cleanCurrentValue = if ($null -eq $CurrentValue) { '' } else { $CurrentValue } foreach ($suggestion in ($Suggestions | Sort-Object -Unique)) { if ($suggestion.StartsWith($cleanCurrentValue, [System.StringComparison]::OrdinalIgnoreCase)) { $tokenText = $Prefix + $suggestion New-XcopyCompletionResult -CompletionText $tokenText -ListItemText $tokenText -ResultType 'ParameterValue' -ToolTip $ToolTip } } } function Get-XcopyChainedPathCompletions { param( [string]$Prefix, [string]$CurrentValue, [string]$Kind ) $valuePrefix = '' $currentSegment = $CurrentValue $lastPlusIndex = if ([string]::IsNullOrEmpty($CurrentValue)) { -1 } else { $CurrentValue.LastIndexOf('+') } if ($lastPlusIndex -ge 0) { $valuePrefix = $CurrentValue.Substring(0, $lastPlusIndex + 1) $currentSegment = $CurrentValue.Substring($lastPlusIndex + 1) } @(Get-XcopyPathCompletions -InputPath $currentSegment -Kind $Kind -CompletionPrefix ($Prefix + $valuePrefix)) } function Get-XcopyInlineValueCompletions { param([string]$WordToComplete) Initialize-XcopyCompletionCatalog $cleanWord = Remove-XcopyOuterQuotes $WordToComplete $match = [regex]::Match($cleanWord, '^(?<root>/-?[A-Za-z?][A-Za-z0-9-]*)(?<separator>:)(?<value>.*)$') if (-not $match.Success) { return @() } $key = $match.Groups['root'].Value.ToLowerInvariant() if (-not $script:XcopyCompletionCatalog.OptionInfoByKey.ContainsKey($key)) { return @() } $optionInfo = $script:XcopyCompletionCatalog.OptionInfoByKey[$key] if (-not ($optionInfo.PSObject.Properties.Name -contains 'InlineValueKind')) { return @() } $prefix = $match.Groups['root'].Value + ':' $currentValue = $match.Groups['value'].Value switch ($optionInfo.InlineValueKind) { 'List' { return @(Get-XcopyPrefixedSuggestions -Prefix $prefix -CurrentValue $currentValue -Suggestions $optionInfo.Suggestions -ToolTip $optionInfo.Description) } 'PathChain' { # The value is a path to an exclude-list file; directories are offered (with a # trailing backslash) so the user can navigate to a file outside the current directory. return @(Get-XcopyChainedPathCompletions -Prefix $prefix -CurrentValue $currentValue -Kind 'Any') } } @() } function Get-XcopyOptionCompletions { param([string]$WordToComplete) Initialize-XcopyCompletionCatalog $prefix = if ([string]::IsNullOrWhiteSpace($WordToComplete)) { '' } else { (Remove-XcopyOuterQuotes $WordToComplete).ToUpperInvariant() } foreach ($option in $script:XcopyCompletionCatalog.Options) { if ($option.CompletionText.ToUpperInvariant().StartsWith($prefix) -or $option.Display.ToUpperInvariant().StartsWith($prefix)) { New-XcopyCompletionResult ` -CompletionText $option.CompletionText ` -ListItemText $option.Display ` -ResultType 'ParameterName' ` -ToolTip $option.Description } } } function Complete-Xcopy { param( [string]$wordToComplete, [System.Management.Automation.Language.CommandAst]$commandAst, [int]$cursorPosition ) if (-not (Test-XcopyCommandAvailable)) { return @() } Initialize-XcopyCompletionCatalog $currentWord = if ($null -eq $wordToComplete) { '' } else { $wordToComplete } if ([string]::IsNullOrWhiteSpace($currentWord) -and $cursorPosition -le $commandAst.Extent.EndOffset) { $currentWord = Get-XcopyCurrentToken -Line $commandAst.ToString() -CursorPosition ($cursorPosition - $commandAst.Extent.StartOffset) -Fallback $wordToComplete } if (-not [string]::IsNullOrEmpty($currentWord) -and (Remove-XcopyOuterQuotes $currentWord).StartsWith('/')) { $inlineValueCompletions = @(Get-XcopyInlineValueCompletions -WordToComplete $currentWord) if ($inlineValueCompletions.Count -gt 0) { return $inlineValueCompletions } return @(Get-XcopyOptionCompletions -WordToComplete $currentWord) } $arguments = @(Get-XcopyArgumentList -CommandAst $commandAst -CursorPosition $cursorPosition) $context = Get-XcopyCompletionContext -Arguments $arguments if ($context.Positionals.Count -lt 2) { $results = [System.Collections.Generic.List[System.Management.Automation.CompletionResult]]::new() foreach ($result in @(Get-XcopyPathCompletions -InputPath $currentWord -Kind 'Any')) { $results.Add($result) } if ([string]::IsNullOrWhiteSpace($currentWord)) { foreach ($result in @(Get-XcopyOptionCompletions -WordToComplete $currentWord)) { $results.Add($result) } } return @(Get-XcopyUniqueCompletions -Results $results.ToArray()) } if ([string]::IsNullOrWhiteSpace($currentWord)) { return @(Get-XcopyOptionCompletions -WordToComplete $currentWord) } @() } Register-ArgumentCompleter -Native -CommandName 'xcopy', 'xcopy.exe' -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) Complete-Xcopy -wordToComplete $wordToComplete -commandAst $commandAst -cursorPosition $cursorPosition } |