diff --git a/PSCommandHelper/Public/Register-PSCommandHelperPrompt.ps1 b/PSCommandHelper/Public/Register-PSCommandHelperPrompt.ps1 index fb1c1e9..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 = { @@ -57,7 +67,28 @@ 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 + } + } + } + + $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 @@ -66,13 +97,43 @@ 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/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 0157a36..0fe5dc6 100644 --- a/tests/PSCommandHelper.Tests.ps1 +++ b/tests/PSCommandHelper.Tests.ps1 @@ -150,6 +150,247 @@ Describe 'Register/Unregister-PSCommandHelperPrompt' { } } +Describe 'Prompt handler: reverse alias lookup' { + BeforeAll { + 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 + } + } + } + } + + It 'rm is in the aliased map' { + InModuleScope PSCommandHelper { + $script:TestAliasedMap.ContainsKey('rm') | Should -BeTrue + } + } + + 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 'resolves Remove-Item to aliased suggestions by alias or cmdlet fallback' { + InModuleScope PSCommandHelper { + $resolvedEntries = $null + $aliases = Get-Alias -Definition 'Remove-Item' -ErrorAction SilentlyContinue + foreach ($a in $aliases) { + if ($script:TestAliasedMap.ContainsKey($a.Name)) { + $resolvedEntries = $script:TestAliasedMap[$a.Name] + break + } + } + if (-not $resolvedEntries) { + $resolvedEntries = $script:TestAliasedCmdletMap['Remove-Item'] + } + + $resolvedEntries | Should -Not -BeNullOrEmpty + } + } + + It 'resolves Get-ChildItem to aliased suggestions by alias or cmdlet fallback' { + InModuleScope PSCommandHelper { + $resolvedEntries = $null + $aliases = Get-Alias -Definition 'Get-ChildItem' -ErrorAction SilentlyContinue + foreach ($a in $aliases) { + if ($script:TestAliasedMap.ContainsKey($a.Name)) { + $resolvedEntries = $script:TestAliasedMap[$a.Name] + break + } + } + 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 + } + } + } +} + +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