completers/ru_completer/ru_completer.ps1

# ru tab completion for PowerShell
# Help-driven native completer for ru.exe with registry-aware absolute-path mode and non-loading hive mode.

Set-StrictMode -Version 2.0

if (-not (Get-Variable -Name RuCompletionCatalog -Scope Script -ErrorAction Ignore)) {
    $script:RuCompletionCatalog = @{
        Initialized = $false
        CommandName = $null
        LevelHints  = @('0', '1', '2', '3', '5', '10')
        Switches    = @()
        SwitchByKey = @{}
        RootKeys    = @('HKLM', 'HKCU', 'HKCR', 'HKU', 'HKCC')
        RootLongNames = @{
            'HKLM' = 'HKEY_LOCAL_MACHINE'
            'HKCU' = 'HKEY_CURRENT_USER'
            'HKCR' = 'HKEY_CLASSES_ROOT'
            'HKU'  = 'HKEY_USERS'
            'HKCC' = 'HKEY_CURRENT_CONFIG'
        }
        RootCanonicalByAlias = @{
            'HKLM'                = 'HKLM'
            'HKCU'                = 'HKCU'
            'HKCR'                = 'HKCR'
            'HKU'                 = 'HKU'
            'HKCC'                = 'HKCC'
            'HKEY_LOCAL_MACHINE'  = 'HKLM'
            'HKEY_CURRENT_USER'   = 'HKCU'
            'HKEY_CLASSES_ROOT'   = 'HKCR'
            'HKEY_USERS'          = 'HKU'
            'HKEY_CURRENT_CONFIG' = 'HKCC'
        }
        ChildCache = @{}
        ChildCacheTtlSeconds = 30
        ChildCacheMaxEntries = 256
        MaxChildResults = 300
    }
}

function Resolve-RuCommandName {
    if ($script:RuCompletionCatalog.CommandName) {
        return $script:RuCompletionCatalog.CommandName
    }

    $command = Get-Command -Name ru.exe, ru -ErrorAction SilentlyContinue | Select-Object -First 1
    if ($command) {
        $script:RuCompletionCatalog.CommandName = if ($command.Source) { $command.Source } else { $command.Name }
    }

    $script:RuCompletionCatalog.CommandName
}

function Invoke-RuHelpText {
    $commandName = Resolve-RuCommandName
    if (-not $commandName) {
        return @()
    }

    try {
        @($null | & $commandName '/?' 2>$null | ForEach-Object { $_ -replace '\e\[[0-9;?]*[ -/]*[@-~]', '' })
    } catch {
        @()
    }
}

function New-RuCompletionResult {
    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-RuOuterQuotes {
    param([string]$Value)

    if ($null -eq $Value) {
        return ''
    }

    # An open quote has no closing partner yet. Single-quoted text is literal
    # apart from a doubled quote; elsewhere a backtick escapes the next character.
    if ($Value.StartsWith("'")) {
        return ($Value -replace "^'|'$", '').Replace("''", "'")
    }

    [regex]::Replace(($Value -replace '^"', ''), '`(.)|"$', { param($match) $match.Groups[1].Value })
}

function ConvertTo-RuQuotedValue {
    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('"', '`"').Replace('$', '`$')
        return '"' + $escaped + '"'
    }

    $Value
}

function Initialize-RuCompletionCatalog {
    if ($script:RuCompletionCatalog.Initialized) {
        return
    }

    $catalog = [ordered]@{
        '-c'        = [pscustomobject]@{ Token = '-c'; Description = 'Print output as CSV.'; TakesValue = $false }
        '-ct'       = [pscustomobject]@{ Token = '-ct'; Description = 'Print CSV output with tab delimiters.'; TakesValue = $false }
        '-h'        = [pscustomobject]@{ Token = '-h'; Description = 'Load the specified hive file, analyze it, then unload it.'; TakesValue = $true; ValueKind = 'HiveFile' }
        '-l'        = [pscustomobject]@{ Token = '-l'; Description = 'Specify subkey depth of information.'; TakesValue = $true; ValueKind = 'Levels' }
        '-n'        = [pscustomobject]@{ Token = '-n'; Description = 'Do not recurse.'; TakesValue = $false }
        '-q'        = [pscustomobject]@{ Token = '-q'; Description = 'Quiet mode.'; TakesValue = $false }
        '-v'        = [pscustomobject]@{ Token = '-v'; Description = 'Show size of all subkeys.'; TakesValue = $false }
        '-nobanner' = [pscustomobject]@{ Token = '-nobanner'; Description = 'Do not display the startup banner and copyright message.'; TakesValue = $false }
        '/?'        = [pscustomobject]@{ Token = '/?'; Description = 'Show ru help.'; TakesValue = $false }
    }

    # ru /? wraps option bodies onto indented continuation lines (-c and -h), and
    # -nobanner starts its body on the following line, so join every indented line
    # that follows an option token onto that option's description.
    $helpEntries = [ordered]@{}
    $currentToken = $null
    foreach ($line in (Invoke-RuHelpText)) {
        if ($line -match '^\s*(-c(?:\[t\])?|-h|-l|-n|-q|-v|-nobanner)(?:\s{2,}(.*))?$') {
            $currentToken = $matches[1].ToLowerInvariant()
            $helpEntries[$currentToken] = if ($matches.Count -gt 2) { $matches[2].Trim() } else { '' }
            continue
        }

        if ($currentToken -and $line -match '^\s{6,}(\S.*)$') {
            $helpEntries[$currentToken] = ($helpEntries[$currentToken] + ' ' + $matches[1].Trim()).Trim()
            continue
        }

        $currentToken = $null
    }

    foreach ($token in $helpEntries.Keys) {
        $description = $helpEntries[$token]
        if ([string]::IsNullOrWhiteSpace($description)) {
            continue
        }

        if ($token -eq '-c[t]') {
            $catalog['-c'] = [pscustomobject]@{ Token = '-c'; Description = $description; TakesValue = $false }
            $catalog['-ct'] = [pscustomobject]@{ Token = '-ct'; Description = 'Print output as CSV with tab delimiters.'; TakesValue = $false }
        } elseif ($catalog.Contains($token)) {
            $entry = $catalog[$token]
            $catalog[$token] = [pscustomobject]@{
                Token       = $entry.Token
                Description = $description
                TakesValue  = $entry.TakesValue
                ValueKind   = if ($entry.PSObject.Properties.Name -contains 'ValueKind') { $entry.ValueKind } else { $null }
            }
        }
    }

    $script:RuCompletionCatalog.Switches = @($catalog.Values)
    $script:RuCompletionCatalog.SwitchByKey = @{}
    foreach ($entry in $script:RuCompletionCatalog.Switches) {
        $script:RuCompletionCatalog.SwitchByKey[$entry.Token.ToLowerInvariant()] = $entry
    }

    $script:RuCompletionCatalog.Initialized = $true
}

function Get-RuCurrentToken {
    param(
        [System.Management.Automation.Language.CommandAst]$CommandAst,
        [int]$CursorPosition,
        [string]$Fallback
    )

    # The parser keeps an unterminated quote or a backtick-escaped space in one
    # element, so the word under the cursor is that element's text up to the cursor.
    foreach ($element in $CommandAst.CommandElements | Select-Object -Skip 1) {
        $extent = $element.Extent
        if ($extent.StartOffset -le $CursorPosition -and $CursorPosition -le $extent.EndOffset) {
            return $extent.Text.Substring(0, $CursorPosition - $extent.StartOffset)
        }
    }

    $Fallback
}

function Get-RuArgumentTokens {
    param(
        [System.Management.Automation.Language.CommandAst]$CommandAst,
        [int]$CursorPosition
    )

    $tokens = @()
    foreach ($element in $CommandAst.CommandElements | Select-Object -Skip 1) {
        if ($element.Extent.EndOffset -lt $CursorPosition) {
            $tokens += $element.Extent.Text
        }
    }

    $tokens
}

function Get-RuState {
    param([string[]]$TokensBeforeCurrent)

    Initialize-RuCompletionCatalog

    $usedSwitches = @{}
    $positionals = New-Object System.Collections.Generic.List[string]
    $pendingValueKind = $null
    $helpRequested = $false
    $hiveFile = $null

    foreach ($token in $TokensBeforeCurrent) {
        $cleanToken = Remove-RuOuterQuotes -Value $token
        if ([string]::IsNullOrWhiteSpace($cleanToken)) {
            continue
        }

        if ($pendingValueKind) {
            if ($pendingValueKind -eq 'HiveFile') {
                $hiveFile = $cleanToken
            }
            $pendingValueKind = $null
            continue
        }

        $lookup = $cleanToken.ToLowerInvariant()
        if ($script:RuCompletionCatalog.SwitchByKey.ContainsKey($lookup)) {
            $usedSwitches[$lookup] = $true
            $switchSpec = $script:RuCompletionCatalog.SwitchByKey[$lookup]
            if ($lookup -eq '/?') {
                $helpRequested = $true
            }
            if ($switchSpec.TakesValue) {
                $pendingValueKind = $switchSpec.ValueKind
            }
            continue
        }

        $positionals.Add($cleanToken)
    }

    [pscustomobject]@{
        UsedSwitches      = $usedSwitches
        Positionals       = @($positionals)
        PendingValueKind  = $pendingValueKind
        HelpRequested     = $helpRequested
        HiveMode          = $usedSwitches.ContainsKey('-h')
        HiveFile          = $hiveFile
    }
}

function Get-RuSwitchCompletions {
    param(
        [string]$CurrentWord,
        [pscustomobject]$State
    )

    $cleanCurrent = Remove-RuOuterQuotes -Value $CurrentWord
    $depthModeUsed = ($State.UsedSwitches.ContainsKey('-l') -or $State.UsedSwitches.ContainsKey('-n') -or $State.UsedSwitches.ContainsKey('-v'))

    foreach ($switchSpec in $script:RuCompletionCatalog.Switches) {
        $lookup = $switchSpec.Token.ToLowerInvariant()
        if ($State.UsedSwitches.ContainsKey($lookup)) {
            continue
        }

        if ($switchSpec.Token -eq '-ct' -and $State.UsedSwitches.ContainsKey('-c')) {
            continue
        }

        if ($switchSpec.Token -eq '-c' -and $State.UsedSwitches.ContainsKey('-ct')) {
            continue
        }

        if ($depthModeUsed -and $switchSpec.Token -in @('-l', '-n', '-v')) {
            continue
        }

        if ($switchSpec.Token.StartsWith($cleanCurrent, [System.StringComparison]::OrdinalIgnoreCase)) {
            New-RuCompletionResult -CompletionText $switchSpec.Token -ListItemText $switchSpec.Token -ResultType 'ParameterName' -ToolTip $switchSpec.Description
        }
    }
}

function Get-RuFileCompletions {
    param([string]$InputPath)

    $cleanInput = Remove-RuOuterQuotes -Value $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)
    $items = @(Get-ChildItem -LiteralPath $parent -ErrorAction SilentlyContinue)
    $items = $items | Where-Object { $_.Name -like ([System.Management.Automation.WildcardPattern]::Escape($leaf) + '*') }

    foreach ($item in ($items | Sort-Object -Property @{ Expression = 'PSIsContainer'; Descending = $true }, 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 += '\'
        }

        $quoted = ConvertTo-RuQuotedValue -Value $pathText -AlwaysQuote $alwaysQuote
        $resultType = if ($item.PSIsContainer) { 'ProviderContainer' } else { 'ParameterValue' }
        New-RuCompletionResult -CompletionText $quoted -ListItemText $pathText -ResultType $resultType -ToolTip $item.FullName
    }
}

function Get-RuLevelCompletions {
    param([string]$CurrentWord)

    $cleanCurrent = Remove-RuOuterQuotes -Value $CurrentWord
    $results = New-Object System.Collections.Generic.List[object]

    foreach ($hint in $script:RuCompletionCatalog.LevelHints) {
        if ($hint.StartsWith($cleanCurrent, [System.StringComparison]::OrdinalIgnoreCase)) {
            $results.Add((New-RuCompletionResult -CompletionText $hint -ListItemText $hint -ResultType 'ParameterValue' -ToolTip 'Subkey depth for ru -l.'))
        }
    }

    if ($results.Count -eq 0 -and -not [string]::IsNullOrWhiteSpace($CurrentWord)) {
        $results.Add((New-RuCompletionResult -CompletionText $CurrentWord -ListItemText $CurrentWord -ResultType 'ParameterValue' -ToolTip 'Subkey depth for ru -l.'))
    }

    if ($results.Count -eq 0 -and [string]::IsNullOrWhiteSpace($CurrentWord)) {
        $results.Add((New-RuCompletionResult -CompletionText ' ' -ListItemText '<levels>' -ResultType 'ParameterValue' -ToolTip 'Subkey depth for ru -l.'))
    }

    @($results.ToArray())
}

function Get-RuRootSuggestions {
    param([string]$CurrentValue)

    $cleanCurrent = Remove-RuOuterQuotes -Value $CurrentValue
    $preferLongNames = $cleanCurrent.StartsWith('HKEY_', [System.StringComparison]::OrdinalIgnoreCase)
    foreach ($root in $script:RuCompletionCatalog.RootKeys) {
        $displayRoot = if ($preferLongNames) { $script:RuCompletionCatalog.RootLongNames[$root] } else { $root }
        $candidate = $displayRoot + '\'
        if ($candidate.StartsWith($cleanCurrent, [System.StringComparison]::OrdinalIgnoreCase) -or $displayRoot.StartsWith($cleanCurrent, [System.StringComparison]::OrdinalIgnoreCase)) {
            New-RuCompletionResult -CompletionText $candidate -ListItemText $candidate -ResultType 'ParameterValue' -ToolTip ('Absolute registry path for ru: ' + $displayRoot)
        }
    }
}

function Get-RuChildKeyState {
    param(
        [string]$CanonicalRoot,
        [string]$SubKeyPath
    )

    # Entries expire so keys created or deleted during the session show up on a later
    # Tab, and the table is cleared once it grows past its bound.
    $cache = $script:RuCompletionCatalog.ChildCache
    $now = [datetime]::UtcNow
    $cacheKey = $CanonicalRoot + '\' + $SubKeyPath
    if ($cache.ContainsKey($cacheKey)) {
        $entry = $cache[$cacheKey]
        if (($now - $entry.Captured).TotalSeconds -lt $script:RuCompletionCatalog.ChildCacheTtlSeconds) {
            return $entry
        }
    }

    # RegistryKey.GetSubKeyNames lists the names held by the parent without opening
    # each child, so it is ~40x faster than the Registry provider on HKCR and still
    # lists keys the session cannot open (SECURITY, BCD00000000). Opening a key the
    # session may not read throws, and that is recorded as Denied so the slot can
    # say so instead of looking empty.
    $baseKey = switch ($CanonicalRoot) {
        'HKLM' { [Microsoft.Win32.Registry]::LocalMachine }
        'HKCU' { [Microsoft.Win32.Registry]::CurrentUser }
        'HKCR' { [Microsoft.Win32.Registry]::ClassesRoot }
        'HKU' { [Microsoft.Win32.Registry]::Users }
        'HKCC' { [Microsoft.Win32.Registry]::CurrentConfig }
        default { $null }
    }

    $names = @()
    $denied = $false
    $subKey = $null
    $errorCountBefore = $Error.Count
    try {
        if ($null -ne $baseKey) {
            if ([string]::IsNullOrEmpty($SubKeyPath)) {
                $names = @($baseKey.GetSubKeyNames())
            } else {
                $subKey = $baseKey.OpenSubKey($SubKeyPath, $false)
                if ($null -ne $subKey) {
                    $names = @($subKey.GetSubKeyNames())
                }
            }
        }
    } catch {
        Write-Debug ('ru completer: cannot enumerate ' + $cacheKey + ': ' + $_.Exception.Message)
        $names = @()
        $denied = $true
    } finally {
        if ($null -ne $subKey) {
            $subKey.Dispose()
        }

        # An unreadable key is an expected outcome while completing, not a fault to
        # leave behind in $Error.
        while ($Error.Count -gt $errorCountBefore) {
            $Error.RemoveAt(0)
        }
    }

    $state = [pscustomobject]@{ Names = @($names); Denied = $denied; Captured = $now }
    if (-not $cache.ContainsKey($cacheKey) -and $cache.Count -ge $script:RuCompletionCatalog.ChildCacheMaxEntries) {
        $cache.Clear()
    }
    $cache[$cacheKey] = $state
    $state
}

function Get-RuRegistryPathCompletions {
    param([string]$CurrentValue)

    $cleanCurrent = Remove-RuOuterQuotes -Value $CurrentValue
    $alwaysQuote = -not [string]::IsNullOrEmpty($CurrentValue) -and ($CurrentValue.StartsWith('"') -or $CurrentValue.StartsWith("'"))

    if ([string]::IsNullOrWhiteSpace($cleanCurrent)) {
        return @(Get-RuRootSuggestions -CurrentValue '')
    }

    if ($cleanCurrent -notmatch '\\') {
        return @(Get-RuRootSuggestions -CurrentValue $cleanCurrent)
    }

    $segments = $cleanCurrent -split '\\', 2
    $typedRoot = $segments[0].ToUpperInvariant()
    if (-not $script:RuCompletionCatalog.RootCanonicalByAlias.ContainsKey($typedRoot)) {
        return @(Get-RuRootSuggestions -CurrentValue $cleanCurrent)
    }

    $canonicalRoot = $script:RuCompletionCatalog.RootCanonicalByAlias[$typedRoot]
    $displayRoot = if ($typedRoot.StartsWith('HKEY_', [System.StringComparison]::OrdinalIgnoreCase)) {
        $script:RuCompletionCatalog.RootLongNames[$canonicalRoot]
    } else {
        $canonicalRoot
    }

    $remainder = if ($segments.Count -gt 1) { $segments[1] } else { '' }
    if ([string]::IsNullOrWhiteSpace($remainder)) {
        $prefixPath = ''
        $leaf = ''
    } elseif ($remainder.EndsWith('\')) {
        $prefixPath = $remainder.TrimEnd('\')
        $leaf = ''
    } else {
        $lastSeparator = $remainder.LastIndexOf('\')
        if ($lastSeparator -lt 0) {
            $prefixPath = ''
            $leaf = $remainder
        } else {
            $prefixPath = $remainder.Substring(0, $lastSeparator)
            $leaf = $remainder.Substring($lastSeparator + 1)
        }
    }

    $childState = Get-RuChildKeyState -CanonicalRoot $canonicalRoot -SubKeyPath $prefixPath
    if ($childState.Denied) {
        # Keep the typed path intact and say why nothing is listed, instead of
        # letting the engine substitute filesystem entries for a registry slot.
        return @(
            New-RuCompletionResult -CompletionText $CurrentValue -ListItemText '<access denied>' -ResultType 'ParameterValue' -ToolTip ('This session cannot read ' + $displayRoot + '\' + $prefixPath + '; run ru elevated to size it.')
        )
    }

    # Filter on the typed leaf before sorting, and cap the emitted count so a hive
    # the size of HKCR cannot stall the completion thread.
    $matchedNames = @(
        $childState.Names |
            Where-Object { $_.StartsWith($leaf, [System.StringComparison]::OrdinalIgnoreCase) } |
            Sort-Object |
            Select-Object -First $script:RuCompletionCatalog.MaxChildResults
    )

    foreach ($childName in $matchedNames) {
        $candidate = if ([string]::IsNullOrWhiteSpace($prefixPath)) {
            $displayRoot + '\' + $childName + '\'
        } else {
            $displayRoot + '\' + $prefixPath + '\' + $childName + '\'
        }

        $quoted = ConvertTo-RuQuotedValue -Value $candidate -AlwaysQuote $alwaysQuote
        New-RuCompletionResult -CompletionText $quoted -ListItemText $candidate -ResultType 'ParameterValue' -ToolTip ('Absolute registry path for ru: ' + $candidate.TrimEnd('\'))
    }
}

function Get-RuHiveRelativePathCompletions {
    param([string]$CurrentWord)

    if ([string]::IsNullOrWhiteSpace($CurrentWord)) {
        return @(
            New-RuCompletionResult -CompletionText ' ' -ListItemText '<relative-path>' -ResultType 'ParameterValue' -ToolTip 'Relative path inside the hive file. Completion does not load hives.'
        )
    }

    @(
        New-RuCompletionResult -CompletionText $CurrentWord -ListItemText $CurrentWord -ResultType 'ParameterValue' -ToolTip 'Relative path inside the hive file. Completion does not load hives.'
    )
}

function Complete-Ru {
    param(
        [string]$wordToComplete,
        [System.Management.Automation.Language.CommandAst]$commandAst,
        [int]$cursorPosition
    )

    Initialize-RuCompletionCatalog

    $currentWord = if ($cursorPosition -gt $commandAst.Extent.EndOffset) {
        ''
    } else {
        Get-RuCurrentToken -CommandAst $commandAst -CursorPosition $cursorPosition -Fallback $wordToComplete
    }

    $state = Get-RuState -TokensBeforeCurrent (Get-RuArgumentTokens -CommandAst $commandAst -CursorPosition $cursorPosition)

    if ($state.HelpRequested) {
        return @(
            New-RuCompletionResult -CompletionText ' ' -ListItemText '<complete>' -ResultType 'ParameterValue' -ToolTip 'ru help is terminal for completion.'
        )
    }

    switch ($state.PendingValueKind) {
        'Levels' { return @(Get-RuLevelCompletions -CurrentWord $currentWord) }
        'HiveFile' { return @(Get-RuFileCompletions -InputPath $currentWord) }
    }

    if (-not [string]::IsNullOrEmpty($currentWord) -and $currentWord.StartsWith('-')) {
        return @(Get-RuSwitchCompletions -CurrentWord $currentWord -State $state)
    }

    $results = New-Object System.Collections.Generic.List[object]
    $canOfferRootSwitches = [string]::IsNullOrEmpty($currentWord) -or $state.Positionals.Count -gt 0
    if ($canOfferRootSwitches) {
        foreach ($switchItem in @(Get-RuSwitchCompletions -CurrentWord $currentWord -State $state)) {
            $results.Add($switchItem)
        }
    }

    if ($state.HiveMode) {
        if (-not $state.HiveFile) {
            foreach ($item in @(Get-RuFileCompletions -InputPath $currentWord)) {
                $results.Add($item)
            }
        } elseif ($state.Positionals.Count -eq 0) {
            foreach ($item in @(Get-RuHiveRelativePathCompletions -CurrentWord $currentWord)) {
                $results.Add($item)
            }
        }

        return @($results.ToArray())
    }

    if ($state.Positionals.Count -eq 0) {
        foreach ($item in @(Get-RuRegistryPathCompletions -CurrentValue $currentWord)) {
            $results.Add($item)
        }
    }

    # After the operand only the remaining switches are valid, so the switch list
    # built above is the whole answer here.
    @($results.ToArray())
}

Register-ArgumentCompleter -Native -CommandName 'ru', 'ru.exe' -ScriptBlock {
    param($wordToComplete, $commandAst, $cursorPosition)

    Complete-Ru -wordToComplete $wordToComplete -commandAst $commandAst -cursorPosition $cursorPosition
}