Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 66 additions & 5 deletions PSCommandHelper/Public/Register-PSCommandHelperPrompt.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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
Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ function Unregister-PSCommandHelperPrompt {
$function:global:prompt = $script:OriginalPrompt
$script:OriginalPrompt = $null
$script:AliasedCommandMap = $null
$script:AliasedCmdletMap = $null
}
}
241 changes: 241 additions & 0 deletions tests/PSCommandHelper.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down