Skip to content

Commit d9a2e40

Browse files
ericchansenCopilot
andcommitted
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>
1 parent 33b1b91 commit d9a2e40

2 files changed

Lines changed: 256 additions & 4 deletions

File tree

PSCommandHelper/Public/Register-PSCommandHelperPrompt.ps1

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,18 @@ function Register-PSCommandHelperPrompt {
5757
$lookupName = $cmdName
5858
}
5959

60+
# Reverse lookup: $cmdName may be the resolved cmdlet (e.g. Remove-Item)
61+
# Find aliases that point TO this cmdlet (e.g. rm → Remove-Item)
62+
if (-not $lookupName) {
63+
$reverseAliases = Get-Alias -Definition $cmdName -ErrorAction SilentlyContinue
64+
foreach ($ra in $reverseAliases) {
65+
if ($script:AliasedCommandMap.ContainsKey($ra.Name)) {
66+
$lookupName = $ra.Name
67+
break
68+
}
69+
}
70+
}
71+
6072
if ($lookupName -and $script:AliasedCommandMap.ContainsKey($lookupName)) {
6173
$errString = $lastErr.ToString()
6274
# Only show for parameter-binding or argument errors (bash-style flags)
@@ -66,13 +78,44 @@ function Register-PSCommandHelperPrompt {
6678
$errString -match 'Cannot find a parameter'
6779

6880
if ($isParamError) {
69-
# Find the best matching entry (most specific first)
81+
# Find the best matching entry using flag-aware comparison
7082
$line = if ($lastErr.InvocationInfo) { $lastErr.InvocationInfo.Line.Trim() } else { $lookupName }
7183
$entries = $script:AliasedCommandMap[$lookupName]
72-
$bestMatch = $entries | Sort-Object { $_.Bash.Length } -Descending |
73-
Where-Object { $line -match [regex]::Escape($_.Bash) } |
74-
Select-Object -First 1
7584

85+
# Extract and normalize flags from the typed line
86+
$lineFlags = [System.Collections.Generic.HashSet[char]]::new()
87+
$lineParts = ($line -split '\s+') | Select-Object -Skip 1
88+
foreach ($part in $lineParts) {
89+
if ($part -match '^-([A-Za-z0-9]+)$') {
90+
foreach ($ch in $Matches[1].ToCharArray()) { [void]$lineFlags.Add($ch) }
91+
}
92+
}
93+
94+
$bestMatch = $null
95+
$bestScore = -1
96+
foreach ($entry in $entries) {
97+
$entryFlags = [System.Collections.Generic.HashSet[char]]::new()
98+
$entryParts = ($entry.Bash -split '\s+') | Select-Object -Skip 1
99+
foreach ($part in $entryParts) {
100+
if ($part -match '^-([A-Za-z0-9]+)$') {
101+
foreach ($ch in $Matches[1].ToCharArray()) { [void]$entryFlags.Add($ch) }
102+
}
103+
}
104+
# Score: entry flags must be a subset of typed flags; more flags = better match
105+
if ($entryFlags.Count -gt 0 -and $entryFlags.IsSubsetOf($lineFlags)) {
106+
if ($entryFlags.Count -gt $bestScore) {
107+
$bestScore = $entryFlags.Count
108+
$bestMatch = $entry
109+
}
110+
}
111+
}
112+
113+
# Fallback: substring match or base command
114+
if (-not $bestMatch) {
115+
$bestMatch = $entries | Sort-Object { $_.Bash.Length } -Descending |
116+
Where-Object { $line -match [regex]::Escape($_.Bash) } |
117+
Select-Object -First 1
118+
}
76119
if (-not $bestMatch) {
77120
$bestMatch = $entries | Select-Object -First 1
78121
}

tests/PSCommandHelper.Tests.ps1

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,215 @@ Describe 'Register/Unregister-PSCommandHelperPrompt' {
150150
}
151151
}
152152

153+
Describe 'Prompt handler: reverse alias lookup' {
154+
BeforeAll {
155+
InModuleScope PSCommandHelper {
156+
$map = Get-BashToPowerShellMap
157+
$script:TestAliasedMap = @{}
158+
foreach ($entry in ($map | Where-Object { $_.Type -eq 'Aliased' })) {
159+
$baseCmd = ($entry.Bash -split '\s+')[0]
160+
if (-not $script:TestAliasedMap.ContainsKey($baseCmd)) {
161+
$script:TestAliasedMap[$baseCmd] = @()
162+
}
163+
$script:TestAliasedMap[$baseCmd] += $entry
164+
}
165+
}
166+
}
167+
168+
It 'rm is in the aliased map' {
169+
InModuleScope PSCommandHelper {
170+
$script:TestAliasedMap.ContainsKey('rm') | Should -BeTrue
171+
}
172+
}
173+
174+
It 'Remove-Item reverse-resolves to rm which is in the aliased map' {
175+
$aliases = Get-Alias -Definition 'Remove-Item' -ErrorAction SilentlyContinue
176+
$aliasNames = $aliases | ForEach-Object { $_.Name }
177+
$aliasNames | Should -Contain 'rm'
178+
}
179+
180+
It 'Get-ChildItem reverse-resolves to ls which is in the aliased map' {
181+
$aliases = Get-Alias -Definition 'Get-ChildItem' -ErrorAction SilentlyContinue
182+
$aliasNames = $aliases | ForEach-Object { $_.Name }
183+
$aliasNames | Should -Contain 'ls'
184+
}
185+
186+
It 'Copy-Item reverse-resolves to cp which is in the aliased map' {
187+
InModuleScope PSCommandHelper {
188+
$aliases = Get-Alias -Definition 'Copy-Item' -ErrorAction SilentlyContinue
189+
$found = $false
190+
foreach ($a in $aliases) {
191+
if ($script:TestAliasedMap.ContainsKey($a.Name)) {
192+
$found = $true
193+
break
194+
}
195+
}
196+
$found | Should -BeTrue
197+
}
198+
}
199+
200+
It 'Move-Item reverse-resolves to mv which is in the aliased map' {
201+
InModuleScope PSCommandHelper {
202+
$aliases = Get-Alias -Definition 'Move-Item' -ErrorAction SilentlyContinue
203+
$found = $false
204+
foreach ($a in $aliases) {
205+
if ($script:TestAliasedMap.ContainsKey($a.Name)) {
206+
$found = $true
207+
break
208+
}
209+
}
210+
$found | Should -BeTrue
211+
}
212+
}
213+
}
214+
215+
Describe 'Flag-aware matching' {
216+
BeforeAll {
217+
InModuleScope PSCommandHelper {
218+
$map = Get-BashToPowerShellMap
219+
$script:TestRmEntries = $map | Where-Object { ($_.Bash -split '\s+')[0] -eq 'rm' -and $_.Type -eq 'Aliased' }
220+
$script:TestLsEntries = $map | Where-Object { ($_.Bash -split '\s+')[0] -eq 'ls' -and $_.Type -eq 'Aliased' }
221+
}
222+
}
223+
224+
It 'rm -fr matches rm -rf entry (flag reorder)' {
225+
InModuleScope PSCommandHelper {
226+
$line = 'rm -fr somedir'
227+
$lineFlags = [System.Collections.Generic.HashSet[char]]::new()
228+
$lineParts = ($line -split '\s+') | Select-Object -Skip 1
229+
foreach ($part in $lineParts) {
230+
if ($part -match '^-([A-Za-z0-9]+)$') {
231+
foreach ($ch in $Matches[1].ToCharArray()) { [void]$lineFlags.Add($ch) }
232+
}
233+
}
234+
235+
$bestMatch = $null
236+
$bestScore = -1
237+
foreach ($entry in $script:TestRmEntries) {
238+
$entryFlags = [System.Collections.Generic.HashSet[char]]::new()
239+
$entryParts = ($entry.Bash -split '\s+') | Select-Object -Skip 1
240+
foreach ($part in $entryParts) {
241+
if ($part -match '^-([A-Za-z0-9]+)$') {
242+
foreach ($ch in $Matches[1].ToCharArray()) { [void]$entryFlags.Add($ch) }
243+
}
244+
}
245+
if ($entryFlags.Count -gt 0 -and $entryFlags.IsSubsetOf($lineFlags)) {
246+
if ($entryFlags.Count -gt $bestScore) {
247+
$bestScore = $entryFlags.Count
248+
$bestMatch = $entry
249+
}
250+
}
251+
}
252+
253+
$bestMatch | Should -Not -BeNull
254+
$bestMatch.Bash | Should -Be 'rm -rf'
255+
$bestMatch.PowerShell | Should -Be 'Remove-Item -Recurse -Force'
256+
}
257+
}
258+
259+
It 'rm -r -f matches rm -rf entry (separated flags)' {
260+
InModuleScope PSCommandHelper {
261+
$line = 'rm -r -f somedir'
262+
$lineFlags = [System.Collections.Generic.HashSet[char]]::new()
263+
$lineParts = ($line -split '\s+') | Select-Object -Skip 1
264+
foreach ($part in $lineParts) {
265+
if ($part -match '^-([A-Za-z0-9]+)$') {
266+
foreach ($ch in $Matches[1].ToCharArray()) { [void]$lineFlags.Add($ch) }
267+
}
268+
}
269+
270+
$bestMatch = $null
271+
$bestScore = -1
272+
foreach ($entry in $script:TestRmEntries) {
273+
$entryFlags = [System.Collections.Generic.HashSet[char]]::new()
274+
$entryParts = ($entry.Bash -split '\s+') | Select-Object -Skip 1
275+
foreach ($part in $entryParts) {
276+
if ($part -match '^-([A-Za-z0-9]+)$') {
277+
foreach ($ch in $Matches[1].ToCharArray()) { [void]$entryFlags.Add($ch) }
278+
}
279+
}
280+
if ($entryFlags.Count -gt 0 -and $entryFlags.IsSubsetOf($lineFlags)) {
281+
if ($entryFlags.Count -gt $bestScore) {
282+
$bestScore = $entryFlags.Count
283+
$bestMatch = $entry
284+
}
285+
}
286+
}
287+
288+
$bestMatch | Should -Not -BeNull
289+
$bestMatch.Bash | Should -Be 'rm -rf'
290+
}
291+
}
292+
293+
It 'ls -al matches ls -la entry (flag reorder)' {
294+
InModuleScope PSCommandHelper {
295+
$line = 'ls -al'
296+
$lineFlags = [System.Collections.Generic.HashSet[char]]::new()
297+
$lineParts = ($line -split '\s+') | Select-Object -Skip 1
298+
foreach ($part in $lineParts) {
299+
if ($part -match '^-([A-Za-z0-9]+)$') {
300+
foreach ($ch in $Matches[1].ToCharArray()) { [void]$lineFlags.Add($ch) }
301+
}
302+
}
303+
304+
$bestMatch = $null
305+
$bestScore = -1
306+
foreach ($entry in $script:TestLsEntries) {
307+
$entryFlags = [System.Collections.Generic.HashSet[char]]::new()
308+
$entryParts = ($entry.Bash -split '\s+') | Select-Object -Skip 1
309+
foreach ($part in $entryParts) {
310+
if ($part -match '^-([A-Za-z0-9]+)$') {
311+
foreach ($ch in $Matches[1].ToCharArray()) { [void]$entryFlags.Add($ch) }
312+
}
313+
}
314+
if ($entryFlags.Count -gt 0 -and $entryFlags.IsSubsetOf($lineFlags)) {
315+
if ($entryFlags.Count -gt $bestScore) {
316+
$bestScore = $entryFlags.Count
317+
$bestMatch = $entry
318+
}
319+
}
320+
}
321+
322+
$bestMatch | Should -Not -BeNull
323+
$bestMatch.Bash | Should -Be 'ls -la'
324+
}
325+
}
326+
327+
It 'rm -rf still matches exactly (no regression)' {
328+
InModuleScope PSCommandHelper {
329+
$line = 'rm -rf somedir'
330+
$lineFlags = [System.Collections.Generic.HashSet[char]]::new()
331+
$lineParts = ($line -split '\s+') | Select-Object -Skip 1
332+
foreach ($part in $lineParts) {
333+
if ($part -match '^-([A-Za-z0-9]+)$') {
334+
foreach ($ch in $Matches[1].ToCharArray()) { [void]$lineFlags.Add($ch) }
335+
}
336+
}
337+
338+
$bestMatch = $null
339+
$bestScore = -1
340+
foreach ($entry in $script:TestRmEntries) {
341+
$entryFlags = [System.Collections.Generic.HashSet[char]]::new()
342+
$entryParts = ($entry.Bash -split '\s+') | Select-Object -Skip 1
343+
foreach ($part in $entryParts) {
344+
if ($part -match '^-([A-Za-z0-9]+)$') {
345+
foreach ($ch in $Matches[1].ToCharArray()) { [void]$entryFlags.Add($ch) }
346+
}
347+
}
348+
if ($entryFlags.Count -gt 0 -and $entryFlags.IsSubsetOf($lineFlags)) {
349+
if ($entryFlags.Count -gt $bestScore) {
350+
$bestScore = $entryFlags.Count
351+
$bestMatch = $entry
352+
}
353+
}
354+
}
355+
356+
$bestMatch | Should -Not -BeNull
357+
$bestMatch.Bash | Should -Be 'rm -rf'
358+
}
359+
}
360+
}
361+
153362
Describe 'Enable/Disable-PSCommandHelper' {
154363
AfterEach {
155364
Disable-PSCommandHelper 6>&1 | Out-Null

0 commit comments

Comments
 (0)