completers/RegDelNull_completer/RegDelNull_completer.ps1

# RegDelNull tab completion for PowerShell
# Small native completer for RegDelNull with local registry-path completion and destructive-aware placeholders.

Set-StrictMode -Version 2.0

if (-not (Get-Variable -Name RegDelNullCompletionCatalog -Scope Script -ErrorAction Ignore)) {
    $script:RegDelNullCompletionCatalog = @{
        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'
        }
        RootProviderPaths = @{
            'HKLM' = 'Registry::HKEY_LOCAL_MACHINE'
            'HKCU' = 'Registry::HKEY_CURRENT_USER'
            'HKCR' = 'Registry::HKEY_CLASSES_ROOT'
            'HKU'  = 'Registry::HKEY_USERS'
            'HKCC' = 'Registry::HKEY_CURRENT_CONFIG'
        }
        ChildCache = @{}
        MaxChildResults = 300
        Switches = @(
            @{ Token = '-s'; Description = 'Recurse into subkeys.' }
            @{ Token = '-y'; Description = 'Suppress confirmation before deleting null-embedded keys.' }
            @{ Token = '-nobanner'; Description = 'Do not display the startup banner and copyright message.' }
            @{ Token = '-accepteula'; Description = 'Accept the Sysinternals license agreement (suppresses the first-run EULA dialog).' }
            @{ Token = '/?'; Description = 'Show RegDelNull help.' }
        )
    }
}

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

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

    $Value.Trim([char[]]@([char]34, [char]39))
}

function ConvertTo-RegDelNullQuotedValue {
    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-RegDelNullCurrentToken {
    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-RegDelNullArgumentTokens {
    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-RegDelNullProviderPath {
    param([string]$KeyPath)

    $cleanKeyPath = Remove-RegDelNullOuterQuotes -Value $KeyPath
    if ([string]::IsNullOrWhiteSpace($cleanKeyPath)) {
        return $null
    }

    $root, $rest = $cleanKeyPath -split '\\', 2
    $rootAlias = $root.ToUpperInvariant()
    if (-not $script:RegDelNullCompletionCatalog.RootCanonicalByAlias.ContainsKey($rootAlias)) {
        return $null
    }

    $canonicalRoot = $script:RegDelNullCompletionCatalog.RootCanonicalByAlias[$rootAlias]
    if ([string]::IsNullOrWhiteSpace($rest)) {
        return $script:RegDelNullCompletionCatalog.RootProviderPaths[$canonicalRoot]
    }

    $script:RegDelNullCompletionCatalog.RootProviderPaths[$canonicalRoot] + '\' + $rest
}

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

    $cacheKey = $CanonicalRoot + '\' + $SubKeyPath
    if ($script:RegDelNullCompletionCatalog.ChildCache.ContainsKey($cacheKey)) {
        return @($script:RegDelNullCompletionCatalog.ChildCache[$cacheKey])
    }

    # RegistryKey.GetSubKeyNames lists the names held by the parent without opening
    # each child, so keys the session cannot read (SECURITY, BCD00000000) are still
    # offered, names containing '/' survive intact, and a large hive costs one call.
    $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 = @()
    $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 {
        $names = @()
    } finally {
        if ($null -ne $subKey) {
            $subKey.Dispose()
        }

        # A key this session cannot read is an expected outcome when completing a
        # hive RegDelNull is normally run elevated to repair, not a fault to report.
        while ($Error.Count -gt $errorCountBefore) {
            $Error.RemoveAt(0)
        }
    }

    $script:RegDelNullCompletionCatalog.ChildCache[$cacheKey] = $names
    @($names)
}

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

    $cleanCurrent = Remove-RegDelNullOuterQuotes -Value $CurrentValue
    $preferLongNames = $cleanCurrent.StartsWith('HKEY_', [System.StringComparison]::OrdinalIgnoreCase)
    foreach ($root in $script:RegDelNullCompletionCatalog.RootKeys) {
        $displayRoot = if ($preferLongNames) { $script:RegDelNullCompletionCatalog.RootLongNames[$root] } else { $root }
        $candidate = $displayRoot + '\'
        if ($candidate.StartsWith($cleanCurrent, [System.StringComparison]::OrdinalIgnoreCase) -or $displayRoot.StartsWith($cleanCurrent, [System.StringComparison]::OrdinalIgnoreCase)) {
            New-RegDelNullCompletionResult -CompletionText $candidate -ListItemText $candidate -ResultType 'ParameterValue' -ToolTip ('Registry root ' + $displayRoot)
        }
    }
}

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

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

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

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

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

    $canonicalRoot = $script:RegDelNullCompletionCatalog.RootCanonicalByAlias[$typedRoot]
    $displayRoot = if ($typedRoot.StartsWith('HKEY_', [System.StringComparison]::OrdinalIgnoreCase)) {
        $script:RegDelNullCompletionCatalog.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)
        }
    }

    # 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 = @(
        Get-RegDelNullChildName -CanonicalRoot $canonicalRoot -SubKeyPath $prefixPath |
            Where-Object { $_.StartsWith($leaf, [System.StringComparison]::OrdinalIgnoreCase) } |
            Sort-Object |
            Select-Object -First $script:RegDelNullCompletionCatalog.MaxChildResults
    )

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

        $quoted = ConvertTo-RegDelNullQuotedValue -Value $candidate -AlwaysQuote $alwaysQuote
        New-RegDelNullCompletionResult -CompletionText $quoted -ListItemText $candidate -ResultType 'ParameterValue' -ToolTip ('Registry path to scan for embedded nulls: ' + $candidate.TrimEnd('\'))
    }
}

function Get-RegDelNullSwitchCompletions {
    param(
        [string]$CurrentWord,
        [hashtable]$UsedSwitches
    )

    $cleanCurrent = Remove-RegDelNullOuterQuotes -Value $CurrentWord
    foreach ($switchSpec in $script:RegDelNullCompletionCatalog.Switches) {
        $lookup = $switchSpec.Token.ToLowerInvariant()
        if ($UsedSwitches.ContainsKey($lookup)) {
            continue
        }

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

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

    $currentWord = if ($cursorPosition -gt $commandAst.Extent.EndOffset) {
        ''
    } else {
        Get-RegDelNullCurrentToken -Line $commandAst.ToString() -CursorPosition ($cursorPosition - $commandAst.Extent.StartOffset) -Fallback $wordToComplete
    }

    $usedSwitches = @{}
    $pathProvided = $false
    $helpRequested = $false

    foreach ($token in (Get-RegDelNullArgumentTokens -CommandAst $commandAst -CursorPosition $cursorPosition)) {
        $cleanToken = Remove-RegDelNullOuterQuotes -Value $token
        if ([string]::IsNullOrWhiteSpace($cleanToken)) {
            continue
        }

        if ($cleanToken.StartsWith('-') -or $cleanToken.StartsWith('/')) {
            $lookup = $cleanToken.ToLowerInvariant()
            $usedSwitches[$lookup] = $true
            if ($lookup -eq '/?') {
                $helpRequested = $true
            }
            continue
        }

        $pathProvided = $true
    }

    if ($helpRequested) {
        return @(
            New-RegDelNullCompletionResult -CompletionText ' ' -ListItemText '<complete>' -ResultType 'ParameterValue' -ToolTip 'RegDelNull help is terminal for completion.'
        )
    }

    if (-not [string]::IsNullOrEmpty($currentWord) -and ($currentWord.StartsWith('-') -or $currentWord.StartsWith('/'))) {
        return @(Get-RegDelNullSwitchCompletions -CurrentWord $currentWord -UsedSwitches $usedSwitches)
    }

    if (-not $pathProvided) {
        $results = @()
        $results += @(Get-RegDelNullRegistryPathCompletions -CurrentValue $currentWord)
        if ([string]::IsNullOrEmpty((Remove-RegDelNullOuterQuotes -Value $currentWord))) {
            $results += @(Get-RegDelNullSwitchCompletions -CurrentWord '' -UsedSwitches $usedSwitches)
        }

        if ($results.Count -gt 0) {
            return @($results)
        }

        if (-not [string]::IsNullOrWhiteSpace($currentWord)) {
            return @(
                New-RegDelNullCompletionResult -CompletionText $currentWord -ListItemText $currentWord -ResultType 'ParameterValue' -ToolTip 'Registry path to scan and potentially delete embedded-null keys.'
            )
        }
    }

    if ([string]::IsNullOrWhiteSpace($currentWord)) {
        return @(Get-RegDelNullSwitchCompletions -CurrentWord '' -UsedSwitches $usedSwitches)
    }

    @()
}

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

    Complete-RegDelNull -wordToComplete $wordToComplete -commandAst $commandAst -cursorPosition $cursorPosition
}