completers/takeown_completer/takeown_completer.ps1
|
<# .SYNOPSIS Registers a native PowerShell argument completer for takeown. .DESCRIPTION Provides a static-first native completer for `takeown` and `takeown.exe`. The completer covers: - slash-style switch completion - switch/value state transitions for `/S`, `/U`, `/P`, `/F`, and `/D` - local file and directory completion for `/F` - placeholder-only completion for remote system, user, password, and UNC slots The script keeps its top level compatible with `Import-CompleterScript`. #> Set-StrictMode -Version 2.0 function New-TakeownCompletionResult { param( [string]$CompletionText, [string]$ResultType, [string]$ToolTip, [string]$ListItemText ) if ([string]::IsNullOrWhiteSpace($ListItemText)) { $ListItemText = $CompletionText } if ([string]::IsNullOrWhiteSpace($ToolTip)) { $ToolTip = $ListItemText } [System.Management.Automation.CompletionResult]::new( $CompletionText, $ListItemText, $ResultType, $ToolTip ) } function Remove-TakeownOuterQuotes { param([string]$Value) if ($null -eq $Value) { return '' } $Value.Trim([char[]]@([char]34, [char]39)) } function ConvertFrom-TakeownTypedWord { # The value of a typed word. A word opened with a quote (ASCII or typographic) is read by # the PowerShell tokenizer, which drops the quotes and undoes that quote style's escapes. param([string]$Value) if ($Value -notmatch '^[''"\u2018-\u201E]') { return $Value } $tokens = $null $parseErrors = $null [void][System.Management.Automation.Language.Parser]::ParseInput($Value, [ref]$tokens, [ref]$parseErrors) $tokens[0].Value } function ConvertTo-TakeownQuotedValue { # Renders a value as one PowerShell argument: bare when safe and no quote was typed, # otherwise in the quote the user typed (single by default). Whitespace or an argument-mode # metacharacter (hidden items such as C:\$Recycle.Bin and ~$name.docx lock files included) # would otherwise split or expand the path; a leading dash would read as a parameter. # PowerShell reads ' and U+2018-U+201B as single quotes and " and U+201C-U+201E as double. param( [string]$Value, [string]$QuoteChar = '' ) if ([string]::IsNullOrEmpty($Value)) { return $Value } if (-not $QuoteChar) { if ($Value -notmatch '[\s{}();,|&<>''"`$@#\u2018-\u201E]' -and $Value -notmatch '^[-\u2013-\u2015]') { return $Value } $QuoteChar = "'" } if ($QuoteChar -match '^[''\u2018-\u201B]$') { return $QuoteChar + ($Value -replace '([''\u2018-\u201B])', '$1$1') + $QuoteChar } $QuoteChar + ($Value -replace '([`"$\u201C-\u201E])', '`$1') + $QuoteChar } function Get-TakeownTokenState { param( [string]$Line, [int]$CursorPosition ) if ($null -eq $Line) { $Line = '' } $safeCursor = [Math]::Min([Math]::Max($CursorPosition, 0), $Line.Length) $prefix = $Line.Substring(0, $safeCursor) $tokens = New-Object System.Collections.Generic.List[string] $builder = New-Object System.Text.StringBuilder $quoteChar = [char]0 foreach ($character in $prefix.ToCharArray()) { if (($character -eq [char]34) -or ($character -eq [char]39)) { if ($quoteChar -eq [char]0) { $quoteChar = $character } elseif ($quoteChar -eq $character) { $quoteChar = [char]0 } [void]$builder.Append($character) continue } if ([char]::IsWhiteSpace($character) -and $quoteChar -eq [char]0) { if ($builder.Length -gt 0) { $tokens.Add($builder.ToString()) [void]$builder.Clear() } continue } [void]$builder.Append($character) } $hasTrailingSpace = $prefix -match '\s$' if ($builder.Length -gt 0) { $tokens.Add($builder.ToString()) } if ($hasTrailingSpace) { return [pscustomobject]@{ TokensBeforeCurrent = @($tokens) CurrentToken = '' } } if ($tokens.Count -gt 0) { return [pscustomobject]@{ TokensBeforeCurrent = @($tokens | Select-Object -First ($tokens.Count - 1)) CurrentToken = $tokens[$tokens.Count - 1] } } [pscustomobject]@{ TokensBeforeCurrent = @() CurrentToken = '' } } function Get-TakeownArgumentsFromTokenState { param([pscustomobject]$TokenState) $tokensBeforeCurrent = @($TokenState.TokensBeforeCurrent) $currentArgument = if ($null -eq $TokenState.CurrentToken) { '' } else { $TokenState.CurrentToken } if ($tokensBeforeCurrent.Count -gt 0) { $argumentsBeforeCurrent = @($tokensBeforeCurrent | Select-Object -Skip 1) } else { $argumentsBeforeCurrent = @() } if ($tokensBeforeCurrent.Count -eq 0 -and $currentArgument -match '^(?i)takeown(?:\.exe)?$') { $currentArgument = '' } [pscustomobject]@{ ArgumentsBeforeCurrent = $argumentsBeforeCurrent CurrentArgument = $currentArgument } } function Get-TakeownCatalog { if (Get-Variable -Name TakeownCompletionCatalog -Scope Script -ErrorAction Ignore) { return $script:TakeownCompletionCatalog } $switches = @( [pscustomobject]@{ Token = '/S'; Description = 'Specifies the remote system to connect to.'; ValueKind = 'System'; OptionalValue = $false } [pscustomobject]@{ Token = '/U'; Description = 'Specifies the user context under which the command should execute. Documented after /S.'; ValueKind = 'User'; OptionalValue = $false } [pscustomobject]@{ Token = '/P'; Description = 'Specifies the password for the given user context. Prompts if omitted, so the value may be left out. Documented after /U.'; ValueKind = 'Password'; OptionalValue = $true } [pscustomobject]@{ Token = '/F'; Description = 'Specifies the file or directory name pattern; sharename\filename with /S.'; ValueKind = 'Path'; OptionalValue = $false } [pscustomobject]@{ Token = '/A'; Description = 'Gives ownership to the Administrators group instead of the current user.' } [pscustomobject]@{ Token = '/R'; Description = 'Operates on files in the specified directory and all subdirectories.' } [pscustomobject]@{ Token = '/D'; Description = 'Default answer (Y or N) used during recursive processing when list-folder permission is missing. Requires /R.'; ValueKind = 'Prompt'; OptionalValue = $false } [pscustomobject]@{ Token = '/SKIPSL'; Description = 'Do not follow symbolic links. Only applicable with /R.' } [pscustomobject]@{ Token = '/?'; Description = 'Displays takeown help.' } ) $switchLookup = @{} foreach ($switch in $switches) { $switchLookup[$switch.Token.ToUpperInvariant()] = $switch } $systemSuggestions = New-Object System.Collections.Generic.List[string] if (-not [string]::IsNullOrWhiteSpace($env:COMPUTERNAME)) { $systemSuggestions.Add($env:COMPUTERNAME) } $systemSuggestions.Add('<system>') $userSuggestions = New-Object System.Collections.Generic.List[string] if (-not [string]::IsNullOrWhiteSpace($env:USERNAME)) { $userSuggestions.Add($env:USERNAME) } if (-not [string]::IsNullOrWhiteSpace($env:USERDOMAIN) -and -not [string]::IsNullOrWhiteSpace($env:USERNAME)) { $userSuggestions.Add($env:USERDOMAIN + '\' + $env:USERNAME) } $userSuggestions.Add('<user>') $userSuggestions.Add('<domain\user>') $script:TakeownCompletionCatalog = [pscustomobject]@{ Switches = $switches SwitchLookup = $switchLookup SystemSuggestions = @($systemSuggestions | Select-Object -Unique) UserSuggestions = @($userSuggestions | Select-Object -Unique) PromptSuggestions = @('Y', 'N') } $script:TakeownCompletionCatalog } function Get-TakeownPendingValueSwitch { param([string[]]$ArgumentsBeforeCurrent) if ($ArgumentsBeforeCurrent.Count -eq 0) { return $null } $lastArgument = $ArgumentsBeforeCurrent[$ArgumentsBeforeCurrent.Count - 1] Get-TakeownValueSwitchName -Argument $lastArgument } function Get-TakeownValueSwitchName { param([string]$Argument) if ([string]::IsNullOrWhiteSpace($Argument)) { return $null } switch ($Argument.ToUpperInvariant()) { '/S' { return '/S' } '/U' { return '/U' } '/P' { return '/P' } '/F' { return '/F' } '/D' { return '/D' } } $null } function Get-TakeownState { param([string[]]$Arguments) $state = [pscustomobject]@{ HasSystemValue = $false HasUserValue = $false HasPasswordSwitch = $false HasPasswordValue = $false HasFileValue = $false HasAdministrators = $false HasRecurse = $false HasDefaultAnswerValue = $false HasSkipSymbolicLinks = $false HasHelp = $false UsedSwitches = (New-Object System.Collections.Generic.HashSet[string]([System.StringComparer]::OrdinalIgnoreCase)) } $pendingValueSwitch = $null for ($index = 0; $index -lt $Arguments.Count; $index++) { $argument = $Arguments[$index] if ($argument.StartsWith('/')) { [void]$state.UsedSwitches.Add($argument) } if ($null -ne $pendingValueSwitch) { if (-not [string]::IsNullOrWhiteSpace($argument) -and -not $argument.StartsWith('/')) { switch ($pendingValueSwitch) { '/S' { $state.HasSystemValue = $true } '/U' { $state.HasUserValue = $true } '/P' { $state.HasPasswordValue = $true } '/F' { $state.HasFileValue = $true } '/D' { $state.HasDefaultAnswerValue = $true } } $pendingValueSwitch = $null continue } $pendingValueSwitch = $null } switch ($argument.ToUpperInvariant()) { '/S' { $pendingValueSwitch = '/S' } '/U' { $pendingValueSwitch = '/U' } '/P' { $state.HasPasswordSwitch = $true $pendingValueSwitch = '/P' } '/F' { $pendingValueSwitch = '/F' } '/A' { $state.HasAdministrators = $true } '/R' { $state.HasRecurse = $true } '/D' { $pendingValueSwitch = '/D' } '/SKIPSL' { $state.HasSkipSymbolicLinks = $true } '/?' { $state.HasHelp = $true } } } $state } function Get-TakeownSwitchCompletions { param( [string]$CurrentWord, [pscustomobject]$State ) # takeown accepts its switches in any order, so every switch not yet on the line is # offered; the documented ordering constraints are surfaced as list-item hints instead. $catalog = Get-TakeownCatalog $hints = @{ '/U' = if (-not $State.HasSystemValue) { '(documented after /S)' } else { '' } '/P' = if (-not $State.HasUserValue) { '(documented after /U)' } else { '' } '/D' = if (-not $State.HasRecurse) { '(requires /R)' } else { '' } '/SKIPSL' = if (-not $State.HasRecurse) { '(only with /R)' } else { '' } } foreach ($switch in $catalog.Switches) { $token = $switch.Token if ($State.UsedSwitches.Contains($token)) { continue } if (-not [string]::IsNullOrWhiteSpace($CurrentWord) -and -not $token.StartsWith($CurrentWord, [System.StringComparison]::OrdinalIgnoreCase)) { continue } $listItemText = $token if ($hints.ContainsKey($token) -and $hints[$token]) { $listItemText = '{0} {1}' -f $token, $hints[$token] } New-TakeownCompletionResult -CompletionText $token -ResultType 'ParameterName' -ToolTip $switch.Description -ListItemText $listItemText } } function Get-TakeownValueCompletions { param( [string]$CurrentWord, [string[]]$Suggestions, [string]$Placeholder, [string]$ToolTip, [switch]$PlaceholderOnlyOnMiss ) $typedValue = if ($null -eq $CurrentWord) { '' } else { Remove-TakeownOuterQuotes -Value $CurrentWord } $results = New-Object System.Collections.Generic.List[object] foreach ($suggestion in $Suggestions) { if (-not [string]::IsNullOrWhiteSpace($typedValue) -and -not $suggestion.StartsWith($typedValue, [System.StringComparison]::OrdinalIgnoreCase)) { continue } [void]$results.Add((New-TakeownCompletionResult -CompletionText $suggestion -ResultType 'ParameterValue' -ToolTip $ToolTip -ListItemText $suggestion)) } if ($results.Count -eq 0) { $fallback = if (-not $PlaceholderOnlyOnMiss -and -not [string]::IsNullOrWhiteSpace($CurrentWord)) { $CurrentWord } else { $Placeholder } [void]$results.Add((New-TakeownCompletionResult -CompletionText $fallback -ResultType 'ParameterValue' -ToolTip $ToolTip -ListItemText $fallback)) } @($results.ToArray()) } function Get-TakeownUncPathCompletions { param( [string]$CurrentValue, [string]$ToolTip ) $typedValue = if ($null -eq $CurrentValue) { '' } else { $CurrentValue } $cleanValue = Remove-TakeownOuterQuotes -Value $typedValue $results = New-Object System.Collections.Generic.List[object] $placeholder = if ($cleanValue -match '^\\\\(?<computer>[^\\]+)\\(?<share>[^\\]+)\\') { '\\' + $Matches.computer + '\' + $Matches.share + '\<path>' } elseif ($cleanValue -match '^\\\\(?<computer>[^\\]+)\\') { '\\' + $Matches.computer + '\<share>\' } else { '\\<computer>\' } if (-not [string]::IsNullOrWhiteSpace($typedValue)) { [void]$results.Add((New-TakeownCompletionResult -CompletionText $typedValue -ResultType 'ParameterValue' -ToolTip $ToolTip -ListItemText $typedValue)) } [void]$results.Add((New-TakeownCompletionResult -CompletionText $placeholder -ResultType 'ParameterValue' -ToolTip 'UNC path placeholder. Remote shares are not enumerated during completion.' -ListItemText $placeholder)) @($results.ToArray()) } function Get-TakeownPathCompletions { param( [string]$CurrentValue, [string]$ToolTip ) $typedValue = if ($null -eq $CurrentValue) { '' } else { $CurrentValue } $cleanValue = ConvertFrom-TakeownTypedWord -Value $typedValue $quoteChar = if ($typedValue -match '^[''"\u2018-\u201E]') { $typedValue.Substring(0, 1) } else { '' } $results = New-Object System.Collections.Generic.List[object] if ($cleanValue.StartsWith('\\')) { return @(Get-TakeownUncPathCompletions -CurrentValue $typedValue -ToolTip $ToolTip) } # The directory part is kept exactly as typed ('.\', '../', 'C:\x/', 'C:'), so no typed text is lost. if ($cleanValue -match '^[A-Za-z]:$') { $typedDirectory = $cleanValue + '\' } else { $separatorIndex = $cleanValue.LastIndexOfAny([char[]]@('\', '/')) if ($separatorIndex -lt 0 -and $cleanValue -match '^[A-Za-z]:') { $separatorIndex = 1 } $typedDirectory = $cleanValue.Substring(0, $separatorIndex + 1) } $leaf = $cleanValue.Substring([Math]::Min($typedDirectory.Length, $cleanValue.Length)) $parentPath = if ($typedDirectory) { $typedDirectory } else { '.' } try { # -Force: hidden and system folders (ProgramData, WindowsApps, AppData) are typical takeown targets. $items = @(Get-ChildItem -LiteralPath $parentPath -Force -ErrorAction Ignore) } catch { $items = @() } foreach ($item in $items) { if (-not [string]::IsNullOrWhiteSpace($leaf) -and -not $item.Name.StartsWith($leaf, [System.StringComparison]::OrdinalIgnoreCase)) { continue } $candidate = $typedDirectory + $item.Name # A bare word led by a dash parses as a parameter; lead it with '.\' as PowerShell does. if (-not $typedDirectory -and $candidate -match '^[-\u2013-\u2015]') { $candidate = '.' + [System.IO.Path]::DirectorySeparatorChar + $candidate } if ($item.PSIsContainer) { $candidate += '\' } $completionText = ConvertTo-TakeownQuotedValue -Value $candidate -QuoteChar $quoteChar [void]$results.Add((New-TakeownCompletionResult -CompletionText $completionText -ResultType 'ParameterValue' -ToolTip $item.FullName -ListItemText $completionText)) } if ($results.Count -eq 0) { $fallback = if ([string]::IsNullOrWhiteSpace($typedValue)) { '<path>' } else { $typedValue } [void]$results.Add((New-TakeownCompletionResult -CompletionText $fallback -ResultType 'ParameterValue' -ToolTip $ToolTip -ListItemText $fallback)) } @($results.ToArray()) } function Get-TakeownShareRelativeCompletionList { param( [string]$CurrentValue, [string]$ToolTip ) # With /S the help documents share-relative operands (MyShare\Acme*.doc); the remote # share is never enumerated, so the slot is a progressive placeholder chain. $typedValue = if ($null -eq $CurrentValue) { '' } else { $CurrentValue } $cleanValue = Remove-TakeownOuterQuotes -Value $typedValue $results = New-Object System.Collections.Generic.List[object] if (-not [string]::IsNullOrWhiteSpace($typedValue)) { [void]$results.Add((New-TakeownCompletionResult -CompletionText $typedValue -ResultType 'ParameterValue' -ToolTip $ToolTip -ListItemText $typedValue)) } $share = if ($cleanValue -match '^(?<share>[^\\]+)\\') { $Matches.share } else { '<share>' } foreach ($candidate in @(($share + '\<file>'), ($share + '\*'))) { if ($results.Count -gt 0 -and $candidate -eq $typedValue) { continue } [void]$results.Add((New-TakeownCompletionResult -CompletionText $candidate -ResultType 'ParameterValue' -ToolTip 'Share-relative path on the /S system. Remote shares are not enumerated during completion.' -ListItemText $candidate)) } @($results.ToArray()) } function Get-TakeownTerminalCompletions { param([string]$CurrentWord) $completionText = if ([string]::IsNullOrEmpty($CurrentWord)) { ' ' } else { $CurrentWord } $listItemText = '[terminal] /? already ends the command' $toolTip = 'No further arguments are valid after /?.' @( New-TakeownCompletionResult -CompletionText $completionText -ResultType 'ParameterValue' -ToolTip $toolTip -ListItemText $listItemText ) } function Get-TakeownRequiresRecurseCompletions { param([string]$CurrentWord) $completionText = if ([string]::IsNullOrEmpty($CurrentWord)) { ' ' } else { $CurrentWord } $listItemText = '[requires /R] /D only accepts Y or N with /R' $toolTip = 'takeown only accepts /D values when /R is present.' @( New-TakeownCompletionResult -CompletionText $completionText -ResultType 'ParameterValue' -ToolTip $toolTip -ListItemText $listItemText ) } function Complete-Takeown { param( [string]$wordToComplete, [System.Management.Automation.Language.CommandAst]$commandAst, [int]$cursorPosition ) # $cursorPosition is a whole-line offset; the command text is command-relative. $tokenState = Get-TakeownTokenState -Line $commandAst.ToString() -CursorPosition ($cursorPosition - $commandAst.Extent.StartOffset) $argumentState = Get-TakeownArgumentsFromTokenState -TokenState $tokenState $hasTrailingSpace = [string]::IsNullOrEmpty($wordToComplete) if ($hasTrailingSpace -and -not [string]::IsNullOrEmpty($argumentState.CurrentArgument)) { $currentWord = '' $argumentsBeforeCurrent = @($argumentState.ArgumentsBeforeCurrent + $argumentState.CurrentArgument) } else { $currentWord = if ($null -eq $argumentState.CurrentArgument) { '' } else { $argumentState.CurrentArgument } $argumentsBeforeCurrent = @($argumentState.ArgumentsBeforeCurrent) } $state = Get-TakeownState -Arguments $argumentsBeforeCurrent if ($state.HasHelp) { return @(Get-TakeownTerminalCompletions -CurrentWord $currentWord) } $catalog = Get-TakeownCatalog $expectedValueSwitch = Get-TakeownPendingValueSwitch -ArgumentsBeforeCurrent $argumentsBeforeCurrent $valuePrefix = '' # A switch whose value is optional (/P) yields to the next switch typed in its slot. if ($null -ne $expectedValueSwitch -and $catalog.SwitchLookup[$expectedValueSwitch].OptionalValue -and $currentWord.StartsWith('/')) { $expectedValueSwitch = $null } # A run-together word such as /FC:\Wind is switch plus partial value; the switch itself # typed in full (or a prefix of a longer switch such as /SK) stays a switch completion. if ($null -eq $expectedValueSwitch -and $currentWord.Length -gt 2) { $inlineValueSwitch = Get-TakeownValueSwitchName -Argument $currentWord.Substring(0, 2) $isSwitchPrefix = [bool]@($catalog.Switches | Where-Object { $_.Token.StartsWith($currentWord, [System.StringComparison]::OrdinalIgnoreCase) }).Count if ($null -ne $inlineValueSwitch -and -not $isSwitchPrefix) { $expectedValueSwitch = $inlineValueSwitch $valuePrefix = $currentWord.Substring(0, 2) $currentWord = $currentWord.Substring(2) } } if ($null -ne $expectedValueSwitch) { $valueResults = switch ($expectedValueSwitch) { '/S' { @(Get-TakeownValueCompletions -CurrentWord $currentWord -Suggestions $catalog.SystemSuggestions -Placeholder '<system>' -ToolTip $catalog.SwitchLookup['/S'].Description) } '/U' { @(Get-TakeownValueCompletions -CurrentWord $currentWord -Suggestions $catalog.UserSuggestions -Placeholder '<domain\user>' -ToolTip $catalog.SwitchLookup['/U'].Description) } '/P' { @(Get-TakeownValueCompletions -CurrentWord $currentWord -Suggestions @('<password>') -Placeholder '<password>' -ToolTip 'Password placeholder only. The completer never inspects secrets.' -PlaceholderOnlyOnMiss) } '/F' { if ($state.HasSystemValue -and -not (Remove-TakeownOuterQuotes -Value $currentWord).StartsWith('\\')) { @(Get-TakeownShareRelativeCompletionList -CurrentValue $currentWord -ToolTip $catalog.SwitchLookup['/F'].Description) } else { @(Get-TakeownPathCompletions -CurrentValue $currentWord -ToolTip $catalog.SwitchLookup['/F'].Description) } } '/D' { if (-not $state.HasRecurse) { @(Get-TakeownRequiresRecurseCompletions -CurrentWord $currentWord) } else { @(Get-TakeownValueCompletions -CurrentWord $currentWord -Suggestions $catalog.PromptSuggestions -Placeholder '<Y|N>' -ToolTip $catalog.SwitchLookup['/D'].Description) } } } if ([string]::IsNullOrEmpty($valuePrefix)) { return @($valueResults) } return @( foreach ($result in @($valueResults)) { New-TakeownCompletionResult -CompletionText ($valuePrefix + $result.CompletionText) -ResultType $result.ResultType -ToolTip $result.ToolTip -ListItemText ($valuePrefix + $result.ListItemText) } ) } if ([string]::IsNullOrWhiteSpace($currentWord) -or $currentWord.StartsWith('/')) { return @(Get-TakeownSwitchCompletions -CurrentWord $currentWord -State $state) } @() } Register-ArgumentCompleter -Native -CommandName @('takeown', 'takeown.exe') -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) Complete-Takeown -wordToComplete $wordToComplete -commandAst $commandAst -cursorPosition $cursorPosition } |