From 40e37016d25bf57ca6faf47d645fdae805de0111 Mon Sep 17 00:00:00 2001 From: Eric Hansen Date: Tue, 24 Feb 2026 12:22:05 -0600 Subject: [PATCH 1/2] fix: prompt handler now shows suggestions for aliased commands with bash-style flags Two bugs prevented PSCommandHelper from showing suggestions when users typed aliased commands with bash-style flags (e.g. rm -fr, ls -al): 1. Reverse alias lookup: When rm resolves to Remove-Item, the error's MyCommand.Name is 'Remove-Item', but the aliased map is keyed by 'rm'. Added Get-Alias -Definition reverse lookup to find the alias. 2. Flag-aware matching: Bash flags can be combined in any order (-rf = -fr) or separated (-r -f). Replaced substring regex matching with flag-set comparison that normalizes and compares flag characters. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Public/Register-PSCommandHelperPrompt.ps1 | 51 ++++- tests/PSCommandHelper.Tests.ps1 | 209 ++++++++++++++++++ 2 files changed, 256 insertions(+), 4 deletions(-) diff --git a/PSCommandHelper/Public/Register-PSCommandHelperPrompt.ps1 b/PSCommandHelper/Public/Register-PSCommandHelperPrompt.ps1 index fb1c1e9..d069338 100644 --- a/PSCommandHelper/Public/Register-PSCommandHelperPrompt.ps1 +++ b/PSCommandHelper/Public/Register-PSCommandHelperPrompt.ps1 @@ -57,6 +57,18 @@ function Register-PSCommandHelperPrompt { $lookupName = $cmdName } + # Reverse lookup: $cmdName may be the resolved cmdlet (e.g. Remove-Item) + # Find aliases that point TO this cmdlet (e.g. rm → Remove-Item) + if (-not $lookupName) { + $reverseAliases = Get-Alias -Definition $cmdName -ErrorAction SilentlyContinue + foreach ($ra in $reverseAliases) { + if ($script:AliasedCommandMap.ContainsKey($ra.Name)) { + $lookupName = $ra.Name + break + } + } + } + if ($lookupName -and $script:AliasedCommandMap.ContainsKey($lookupName)) { $errString = $lastErr.ToString() # Only show for parameter-binding or argument errors (bash-style flags) @@ -66,13 +78,44 @@ function Register-PSCommandHelperPrompt { $errString -match 'Cannot find a parameter' if ($isParamError) { - # Find the best matching entry (most specific first) + # Find the best matching entry using flag-aware comparison $line = if ($lastErr.InvocationInfo) { $lastErr.InvocationInfo.Line.Trim() } else { $lookupName } $entries = $script:AliasedCommandMap[$lookupName] - $bestMatch = $entries | Sort-Object { $_.Bash.Length } -Descending | - Where-Object { $line -match [regex]::Escape($_.Bash) } | - Select-Object -First 1 + # Extract and normalize flags from the typed line + $lineFlags = [System.Collections.Generic.HashSet[char]]::new() + $lineParts = ($line -split '\s+') | Select-Object -Skip 1 + foreach ($part in $lineParts) { + if ($part -match '^-([A-Za-z0-9]+)$') { + foreach ($ch in $Matches[1].ToCharArray()) { [void]$lineFlags.Add($ch) } + } + } + + $bestMatch = $null + $bestScore = -1 + foreach ($entry in $entries) { + $entryFlags = [System.Collections.Generic.HashSet[char]]::new() + $entryParts = ($entry.Bash -split '\s+') | Select-Object -Skip 1 + foreach ($part in $entryParts) { + if ($part -match '^-([A-Za-z0-9]+)$') { + foreach ($ch in $Matches[1].ToCharArray()) { [void]$entryFlags.Add($ch) } + } + } + # Score: entry flags must be a subset of typed flags; more flags = better match + if ($entryFlags.Count -gt 0 -and $entryFlags.IsSubsetOf($lineFlags)) { + if ($entryFlags.Count -gt $bestScore) { + $bestScore = $entryFlags.Count + $bestMatch = $entry + } + } + } + + # Fallback: substring match or base command + if (-not $bestMatch) { + $bestMatch = $entries | Sort-Object { $_.Bash.Length } -Descending | + Where-Object { $line -match [regex]::Escape($_.Bash) } | + Select-Object -First 1 + } if (-not $bestMatch) { $bestMatch = $entries | Select-Object -First 1 } diff --git a/tests/PSCommandHelper.Tests.ps1 b/tests/PSCommandHelper.Tests.ps1 index 0157a36..b1bc5f1 100644 --- a/tests/PSCommandHelper.Tests.ps1 +++ b/tests/PSCommandHelper.Tests.ps1 @@ -150,6 +150,215 @@ Describe 'Register/Unregister-PSCommandHelperPrompt' { } } +Describe 'Prompt handler: reverse alias lookup' { + BeforeAll { + InModuleScope PSCommandHelper { + $map = Get-BashToPowerShellMap + $script:TestAliasedMap = @{} + foreach ($entry in ($map | Where-Object { $_.Type -eq 'Aliased' })) { + $baseCmd = ($entry.Bash -split '\s+')[0] + if (-not $script:TestAliasedMap.ContainsKey($baseCmd)) { + $script:TestAliasedMap[$baseCmd] = @() + } + $script:TestAliasedMap[$baseCmd] += $entry + } + } + } + + It 'rm is in the aliased map' { + InModuleScope PSCommandHelper { + $script:TestAliasedMap.ContainsKey('rm') | Should -BeTrue + } + } + + It 'Remove-Item reverse-resolves to rm which is in the aliased map' { + $aliases = Get-Alias -Definition 'Remove-Item' -ErrorAction SilentlyContinue + $aliasNames = $aliases | ForEach-Object { $_.Name } + $aliasNames | Should -Contain 'rm' + } + + It 'Get-ChildItem reverse-resolves to ls which is in the aliased map' { + $aliases = Get-Alias -Definition 'Get-ChildItem' -ErrorAction SilentlyContinue + $aliasNames = $aliases | ForEach-Object { $_.Name } + $aliasNames | Should -Contain 'ls' + } + + It 'Copy-Item reverse-resolves to cp which is in the aliased map' { + InModuleScope PSCommandHelper { + $aliases = Get-Alias -Definition 'Copy-Item' -ErrorAction SilentlyContinue + $found = $false + foreach ($a in $aliases) { + if ($script:TestAliasedMap.ContainsKey($a.Name)) { + $found = $true + break + } + } + $found | Should -BeTrue + } + } + + It 'Move-Item reverse-resolves to mv which is in the aliased map' { + InModuleScope PSCommandHelper { + $aliases = Get-Alias -Definition 'Move-Item' -ErrorAction SilentlyContinue + $found = $false + foreach ($a in $aliases) { + if ($script:TestAliasedMap.ContainsKey($a.Name)) { + $found = $true + break + } + } + $found | Should -BeTrue + } + } +} + +Describe 'Flag-aware matching' { + BeforeAll { + InModuleScope PSCommandHelper { + $map = Get-BashToPowerShellMap + $script:TestRmEntries = $map | Where-Object { ($_.Bash -split '\s+')[0] -eq 'rm' -and $_.Type -eq 'Aliased' } + $script:TestLsEntries = $map | Where-Object { ($_.Bash -split '\s+')[0] -eq 'ls' -and $_.Type -eq 'Aliased' } + } + } + + It 'rm -fr matches rm -rf entry (flag reorder)' { + InModuleScope PSCommandHelper { + $line = 'rm -fr somedir' + $lineFlags = [System.Collections.Generic.HashSet[char]]::new() + $lineParts = ($line -split '\s+') | Select-Object -Skip 1 + foreach ($part in $lineParts) { + if ($part -match '^-([A-Za-z0-9]+)$') { + foreach ($ch in $Matches[1].ToCharArray()) { [void]$lineFlags.Add($ch) } + } + } + + $bestMatch = $null + $bestScore = -1 + foreach ($entry in $script:TestRmEntries) { + $entryFlags = [System.Collections.Generic.HashSet[char]]::new() + $entryParts = ($entry.Bash -split '\s+') | Select-Object -Skip 1 + foreach ($part in $entryParts) { + if ($part -match '^-([A-Za-z0-9]+)$') { + foreach ($ch in $Matches[1].ToCharArray()) { [void]$entryFlags.Add($ch) } + } + } + if ($entryFlags.Count -gt 0 -and $entryFlags.IsSubsetOf($lineFlags)) { + if ($entryFlags.Count -gt $bestScore) { + $bestScore = $entryFlags.Count + $bestMatch = $entry + } + } + } + + $bestMatch | Should -Not -BeNull + $bestMatch.Bash | Should -Be 'rm -rf' + $bestMatch.PowerShell | Should -Be 'Remove-Item -Recurse -Force' + } + } + + It 'rm -r -f matches rm -rf entry (separated flags)' { + InModuleScope PSCommandHelper { + $line = 'rm -r -f somedir' + $lineFlags = [System.Collections.Generic.HashSet[char]]::new() + $lineParts = ($line -split '\s+') | Select-Object -Skip 1 + foreach ($part in $lineParts) { + if ($part -match '^-([A-Za-z0-9]+)$') { + foreach ($ch in $Matches[1].ToCharArray()) { [void]$lineFlags.Add($ch) } + } + } + + $bestMatch = $null + $bestScore = -1 + foreach ($entry in $script:TestRmEntries) { + $entryFlags = [System.Collections.Generic.HashSet[char]]::new() + $entryParts = ($entry.Bash -split '\s+') | Select-Object -Skip 1 + foreach ($part in $entryParts) { + if ($part -match '^-([A-Za-z0-9]+)$') { + foreach ($ch in $Matches[1].ToCharArray()) { [void]$entryFlags.Add($ch) } + } + } + if ($entryFlags.Count -gt 0 -and $entryFlags.IsSubsetOf($lineFlags)) { + if ($entryFlags.Count -gt $bestScore) { + $bestScore = $entryFlags.Count + $bestMatch = $entry + } + } + } + + $bestMatch | Should -Not -BeNull + $bestMatch.Bash | Should -Be 'rm -rf' + } + } + + It 'ls -al matches ls -la entry (flag reorder)' { + InModuleScope PSCommandHelper { + $line = 'ls -al' + $lineFlags = [System.Collections.Generic.HashSet[char]]::new() + $lineParts = ($line -split '\s+') | Select-Object -Skip 1 + foreach ($part in $lineParts) { + if ($part -match '^-([A-Za-z0-9]+)$') { + foreach ($ch in $Matches[1].ToCharArray()) { [void]$lineFlags.Add($ch) } + } + } + + $bestMatch = $null + $bestScore = -1 + foreach ($entry in $script:TestLsEntries) { + $entryFlags = [System.Collections.Generic.HashSet[char]]::new() + $entryParts = ($entry.Bash -split '\s+') | Select-Object -Skip 1 + foreach ($part in $entryParts) { + if ($part -match '^-([A-Za-z0-9]+)$') { + foreach ($ch in $Matches[1].ToCharArray()) { [void]$entryFlags.Add($ch) } + } + } + if ($entryFlags.Count -gt 0 -and $entryFlags.IsSubsetOf($lineFlags)) { + if ($entryFlags.Count -gt $bestScore) { + $bestScore = $entryFlags.Count + $bestMatch = $entry + } + } + } + + $bestMatch | Should -Not -BeNull + $bestMatch.Bash | Should -Be 'ls -la' + } + } + + It 'rm -rf still matches exactly (no regression)' { + InModuleScope PSCommandHelper { + $line = 'rm -rf somedir' + $lineFlags = [System.Collections.Generic.HashSet[char]]::new() + $lineParts = ($line -split '\s+') | Select-Object -Skip 1 + foreach ($part in $lineParts) { + if ($part -match '^-([A-Za-z0-9]+)$') { + foreach ($ch in $Matches[1].ToCharArray()) { [void]$lineFlags.Add($ch) } + } + } + + $bestMatch = $null + $bestScore = -1 + foreach ($entry in $script:TestRmEntries) { + $entryFlags = [System.Collections.Generic.HashSet[char]]::new() + $entryParts = ($entry.Bash -split '\s+') | Select-Object -Skip 1 + foreach ($part in $entryParts) { + if ($part -match '^-([A-Za-z0-9]+)$') { + foreach ($ch in $Matches[1].ToCharArray()) { [void]$entryFlags.Add($ch) } + } + } + if ($entryFlags.Count -gt 0 -and $entryFlags.IsSubsetOf($lineFlags)) { + if ($entryFlags.Count -gt $bestScore) { + $bestScore = $entryFlags.Count + $bestMatch = $entry + } + } + } + + $bestMatch | Should -Not -BeNull + $bestMatch.Bash | Should -Be 'rm -rf' + } + } +} + Describe 'Enable/Disable-PSCommandHelper' { AfterEach { Disable-PSCommandHelper 6>&1 | Out-Null From ad408f6b9576a125550519e982658f91e2a2d2c5 Mon Sep 17 00:00:00 2001 From: Eric Hansen Date: Tue, 24 Feb 2026 12:30:22 -0600 Subject: [PATCH 2/2] fix: make prompt alias resolution cross-platform - add cmdlet-based fallback map for aliased entries so suggestions still resolve when bash alias names (rm/ls/cp/mv) are not present on the host OS - keep reverse alias lookup for environments where those aliases exist - clear cmdlet fallback cache on unregister - make reverse lookup tests platform-agnostic by validating alias-or-cmdlet fallback behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Public/Register-PSCommandHelperPrompt.ps1 | 20 +++++- .../Unregister-PSCommandHelperPrompt.ps1 | 1 + tests/PSCommandHelper.Tests.ps1 | 72 +++++++++++++------ 3 files changed, 72 insertions(+), 21 deletions(-) diff --git a/PSCommandHelper/Public/Register-PSCommandHelperPrompt.ps1 b/PSCommandHelper/Public/Register-PSCommandHelperPrompt.ps1 index d069338..c4a819e 100644 --- a/PSCommandHelper/Public/Register-PSCommandHelperPrompt.ps1 +++ b/PSCommandHelper/Public/Register-PSCommandHelperPrompt.ps1 @@ -23,14 +23,24 @@ function Register-PSCommandHelperPrompt { # Build the aliased-command lookup from the map $map = Get-BashToPowerShellMap $aliasedMap = @{} + $aliasedCmdletMap = @{} foreach ($entry in ($map | Where-Object { $_.Type -eq 'Aliased' })) { $baseCmd = ($entry.Bash -split '\s+')[0] if (-not $aliasedMap.ContainsKey($baseCmd)) { $aliasedMap[$baseCmd] = @() } $aliasedMap[$baseCmd] += $entry + + $psCmdlet = ($entry.PowerShell -split '\s+')[0] + if ($psCmdlet) { + if (-not $aliasedCmdletMap.ContainsKey($psCmdlet)) { + $aliasedCmdletMap[$psCmdlet] = @() + } + $aliasedCmdletMap[$psCmdlet] += $entry + } } $script:AliasedCommandMap = $aliasedMap + $script:AliasedCmdletMap = $aliasedCmdletMap $script:FormatFunc = Get-Command Format-Suggestion $function:global:prompt = { @@ -69,7 +79,16 @@ function Register-PSCommandHelperPrompt { } } + $entries = $null if ($lookupName -and $script:AliasedCommandMap.ContainsKey($lookupName)) { + $entries = $script:AliasedCommandMap[$lookupName] + } + elseif ($script:AliasedCmdletMap -and $script:AliasedCmdletMap.ContainsKey($cmdName)) { + $entries = $script:AliasedCmdletMap[$cmdName] + $lookupName = $cmdName + } + + if ($entries) { $errString = $lastErr.ToString() # Only show for parameter-binding or argument errors (bash-style flags) $isParamError = $lastErr.Exception -is [System.Management.Automation.ParameterBindingException] -or @@ -80,7 +99,6 @@ function Register-PSCommandHelperPrompt { if ($isParamError) { # Find the best matching entry using flag-aware comparison $line = if ($lastErr.InvocationInfo) { $lastErr.InvocationInfo.Line.Trim() } else { $lookupName } - $entries = $script:AliasedCommandMap[$lookupName] # Extract and normalize flags from the typed line $lineFlags = [System.Collections.Generic.HashSet[char]]::new() diff --git a/PSCommandHelper/Public/Unregister-PSCommandHelperPrompt.ps1 b/PSCommandHelper/Public/Unregister-PSCommandHelperPrompt.ps1 index 071c515..096e08b 100644 --- a/PSCommandHelper/Public/Unregister-PSCommandHelperPrompt.ps1 +++ b/PSCommandHelper/Public/Unregister-PSCommandHelperPrompt.ps1 @@ -10,5 +10,6 @@ function Unregister-PSCommandHelperPrompt { $function:global:prompt = $script:OriginalPrompt $script:OriginalPrompt = $null $script:AliasedCommandMap = $null + $script:AliasedCmdletMap = $null } } diff --git a/tests/PSCommandHelper.Tests.ps1 b/tests/PSCommandHelper.Tests.ps1 index b1bc5f1..0fe5dc6 100644 --- a/tests/PSCommandHelper.Tests.ps1 +++ b/tests/PSCommandHelper.Tests.ps1 @@ -155,12 +155,21 @@ Describe 'Prompt handler: reverse alias lookup' { InModuleScope PSCommandHelper { $map = Get-BashToPowerShellMap $script:TestAliasedMap = @{} + $script:TestAliasedCmdletMap = @{} foreach ($entry in ($map | Where-Object { $_.Type -eq 'Aliased' })) { $baseCmd = ($entry.Bash -split '\s+')[0] if (-not $script:TestAliasedMap.ContainsKey($baseCmd)) { $script:TestAliasedMap[$baseCmd] = @() } $script:TestAliasedMap[$baseCmd] += $entry + + $psCmdlet = ($entry.PowerShell -split '\s+')[0] + if ($psCmdlet) { + if (-not $script:TestAliasedCmdletMap.ContainsKey($psCmdlet)) { + $script:TestAliasedCmdletMap[$psCmdlet] = @() + } + $script:TestAliasedCmdletMap[$psCmdlet] += $entry + } } } } @@ -171,43 +180,66 @@ Describe 'Prompt handler: reverse alias lookup' { } } - It 'Remove-Item reverse-resolves to rm which is in the aliased map' { - $aliases = Get-Alias -Definition 'Remove-Item' -ErrorAction SilentlyContinue - $aliasNames = $aliases | ForEach-Object { $_.Name } - $aliasNames | Should -Contain 'rm' - } - - It 'Get-ChildItem reverse-resolves to ls which is in the aliased map' { - $aliases = Get-Alias -Definition 'Get-ChildItem' -ErrorAction SilentlyContinue - $aliasNames = $aliases | ForEach-Object { $_.Name } - $aliasNames | Should -Contain 'ls' + It 'has cmdlet fallback entries for Remove-Item and Get-ChildItem' { + InModuleScope PSCommandHelper { + $script:TestAliasedCmdletMap.ContainsKey('Remove-Item') | Should -BeTrue + $script:TestAliasedCmdletMap.ContainsKey('Get-ChildItem') | Should -BeTrue + } } - It 'Copy-Item reverse-resolves to cp which is in the aliased map' { + It 'resolves Remove-Item to aliased suggestions by alias or cmdlet fallback' { InModuleScope PSCommandHelper { - $aliases = Get-Alias -Definition 'Copy-Item' -ErrorAction SilentlyContinue - $found = $false + $resolvedEntries = $null + $aliases = Get-Alias -Definition 'Remove-Item' -ErrorAction SilentlyContinue foreach ($a in $aliases) { if ($script:TestAliasedMap.ContainsKey($a.Name)) { - $found = $true + $resolvedEntries = $script:TestAliasedMap[$a.Name] break } } - $found | Should -BeTrue + if (-not $resolvedEntries) { + $resolvedEntries = $script:TestAliasedCmdletMap['Remove-Item'] + } + + $resolvedEntries | Should -Not -BeNullOrEmpty } } - It 'Move-Item reverse-resolves to mv which is in the aliased map' { + It 'resolves Get-ChildItem to aliased suggestions by alias or cmdlet fallback' { InModuleScope PSCommandHelper { - $aliases = Get-Alias -Definition 'Move-Item' -ErrorAction SilentlyContinue - $found = $false + $resolvedEntries = $null + $aliases = Get-Alias -Definition 'Get-ChildItem' -ErrorAction SilentlyContinue foreach ($a in $aliases) { if ($script:TestAliasedMap.ContainsKey($a.Name)) { - $found = $true + $resolvedEntries = $script:TestAliasedMap[$a.Name] break } } - $found | Should -BeTrue + if (-not $resolvedEntries) { + $resolvedEntries = $script:TestAliasedCmdletMap['Get-ChildItem'] + } + + $resolvedEntries | Should -Not -BeNullOrEmpty + } + } + + It 'resolves Copy-Item and Move-Item by alias or cmdlet fallback' { + InModuleScope PSCommandHelper { + foreach ($cmdlet in @('Copy-Item', 'Move-Item')) { + $resolvedEntries = $null + $aliases = Get-Alias -Definition $cmdlet -ErrorAction SilentlyContinue + foreach ($a in $aliases) { + if ($script:TestAliasedMap.ContainsKey($a.Name)) { + $resolvedEntries = $script:TestAliasedMap[$a.Name] + break + } + } + if (-not $resolvedEntries) { + $resolvedEntries = $script:TestAliasedCmdletMap[$cmdlet] + } + + $resolvedEntries | Should -Not -BeNullOrEmpty + } } } }