Skip to content

Instantly share code, notes, and snippets.

@Jaykul
Last active April 9, 2023 01:41
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Jaykul/b8ed295d32ec2500b7becfed38308521 to your computer and use it in GitHub Desktop.
Save Jaykul/b8ed295d32ec2500b7becfed38308521 to your computer and use it in GitHub Desktop.
Helpfully handling Command Not Found in PowerShell

Helpfully handling Command Not Found in PowerShell

About CommandNotFoundAction

In PowerShell there is a callback ("Action") that is called when a command lookup fails through normal means. The purpose of this callback is to allow you to add your own command-resolution, and there are a lot of community hacks out there already:

Community Implementations

A proposal for a useful default

To be clear: some of those options are awesome -- and in particular, for developers, the git aliases and WSL commands are potentially really helpful. There's nothing wrong with any of that.

However, I don't think I'd want any of them to replace the default error message in PowerShell.

I'm going to show you one here which I think might lead to something generally useful. It's based on the way zsh and some other shells implement typo detection. If you type ehco as a command, it will offer to create an alias for you from that to echo, and you can accept, and run that command just once, or define an alias that will persist. That seems like it would be useful, so ...

What would it take?

First, we need a way to find a similar command. I've written a simple Find-ClosestCommand below. It's based on an edit distance algorithm called Levenshtein distance. I found several implementations of this in PowerShell, the fastest was this one from @gravejester's Communary.PASM module. Of course, using edit distance might end up being too simple, in the long run, but for an experiment, it will work.

As background: edit distance measures the difference between two strings based on the number of edits (insertion, deletion, or substitution) needed to make them identical. Find-ClosestCommand computes the edit distance from the failed command to each command name available on the system, and returns just one command: the one with the smallest edit distance (or the first one it finds that is only one edit away).

Given Find-ClosestCommand we can implement a CommandNotFoundAction quite simply, but first,

A little background on the action

The callback has two parameters:

  • The name of the command which was not found
  • A CommandLookupEventArgs where you can set the Command if you're able to resolve it

The action is expected to try to find a command, and if it does, to set the Command propert and the StopSearch property on the CommandLookupEventArgs -- after which PowerShell will simply run that command (with all the parameters in the original command line). That is, it could be invisible to the user (and is, in the implementations mentioned above).

Also, if PowerShell doesn't find a matching command, it searches again with .\ prefixed, as part of error handling, because if it finds a file in the local path, it will show a different error.

But when you really can't find the command...

Rather than doing something like insulting the user, we want to try to help with typos and bad guesses.

The simplest possible action would just be this:

$ExecutionContext.InvokeCommand.CommandNotFoundAction = {
    param($Name, $EventArgs)
    $EventArgs.Command = Find-ClosestCommand $CommandName
    $EventArgs.StopSearch = $true
}

The problem is, PowerShell is going to run whatever you find -- and in some cases, what you find might not match very well.
In order to avoid accidentally formatting a hard drive or something crazy like that, we want that confirmation from the user like what zsh does.

For something like this, I like to use a switch statement wrapped around a PromptForChoice. Check out my CommandNotFoundAction below.

Basically, we're just calling Get-ClosestCommand and then asking the user whether the command we found is the right one, and whether they want to remember that typo for the future.

Post Script

Because this is really just an example on a blog post, I did not do anything to persist the aliases, but we could easily:

  1. Add a aliases.ps1 file in the home profile folder
  2. Dot-source that from the profile
  3. Append lines to it whenever the user chooses Always

Trying to come up with deliberate typos for testing was frustrating, but in the process I have already found a few improvements that should be made:

  1. It should prefer missing initializations (i.e. sc for Set-Content) before anything else.
  2. There should be a limit on the distance. E.g. If the distance is greater than the input, there's basically no similarity, and we shouldn't prompt.
$ExecutionContext.InvokeCommand.CommandNotFoundAction = {
param($Name, $EventArgs)
# this prevents us from running again on the .\$Name search and outputting two results
if ([Microsoft.PowerShell.PSConsoleReadLine]::GetHistoryItems()[-1].CommandLine -match ([regex]::Escape($Name))) {
$Result = Get-ClosestCommand $Name -ListImported
switch ($Host.UI.PromptForChoice(
"Command not found '$Name'",
"Did you mean '$Result'?",
[System.Management.Automation.Host.ChoiceDescription[]]("&Yes", "&Always", "&No"), 0)) {
2 { # Nope
break
}
1 { # Always
Set-Alias $Name $Result -Scope Global -Description "Set by user choice from CommandNotFoundAction"
}
{$_ -lt 2} {
$EventArgs.Command = $Result
$EventArgs.StopSearch = $true
}
}
}
}
function Find-ClosestCommand {
<#
.SYNOPSIS
Find the most similar command (based on edit distance)
.DESCRIPTION
Uses Levenshtein distance to determine the command with the closest name. Returns that command with a NoteProperty detailing the `Distance`.
.EXAMPLE
Find-ClosestCommand gct -ListImported
Will find 'gc' (the alias to 'Get-Content'), without searching commands from modules that haven't been imported.
The -ListImported switch can make a huge difference in search time, because it doesn't consider modules that aren't already imported.
#>
[CmdletBinding()]
param(
# The name of the command to search for
[Parameter(Mandatory)]
[string]$Name,
# If set, ignores commands in modules that aren't already imported (making the search much faster)
[switch]$ListImported,
# Limit the search to certain command types (defaults to all)
[System.Management.Automation.CommandTypes]$CommandType = "All"
)
begin {
$closest = @{ Distance = [int]::MaxValue }
}
process {
foreach($Command in Get-Command -ListImported:$ListImported -CommandType:$CommandType) {
$Distance = Measure-LevenshteinDistance $command.Name $Name
if ($Distance -le 1) { # Shortcut for the closest we can get
$Command |
Add-Member -NotePropertyName Distance -NotePropertyValue 1 -PassThru -Force
break
}
if ($closest.Distance -gt $Distance) {
$closest.Distance = $Distance
$closest.Command = $command
}
}
}
end {
if($Distance -gt 1) {
$Closest.Command |
Add-Member -NotePropertyName Distance -NotePropertyValue $closest.Distance -PassThru -Force
}
}
}
function Measure-LevenshteinDistance {
<#
.SYNOPSIS
Measure the Levenshtein edit distance between two strings.
.DESCRIPTION
The Levenshtein Distance is a way of quantifying how dissimilar two strings (e.g., words) are to one another by counting the minimum number of operations required to transform one string into the other.
.EXAMPLE
Get-LevenshteinDistance 'kitten' 'sitting'
.LINK
http://en.wikibooks.org/wiki/Algorithm_Implementation/Strings/Levenshtein_distance#C.23
http://en.wikipedia.org/wiki/Edit_distance
https://communary.wordpress.com/
https://github.com/gravejester/Communary.PASM
.NOTES
Author: Øyvind Kallstad
From: https://github.com/gravejester/Communary.PASM
Date: 07.11.2014
Version: 1.0
#>
[CmdletBinding()]
param(
[Parameter(Position = 0)]
[string]$String1,
[Parameter(Position = 1)]
[string]$String2,
# Makes matches case-sensitive. By default, matches are not case-sensitive.
[Parameter()]
[switch] $CaseSensitive,
# A normalized output will fall in the range 0 (perfect match) to 1 (no match).
[Parameter()]
[switch] $NormalizeOutput
)
if (-not($CaseSensitive)) {
$String1 = $String1.ToLowerInvariant()
$String2 = $String2.ToLowerInvariant()
}
$d = New-Object 'Int[,]' ($String1.Length + 1), ($String2.Length + 1)
try {
for ($i = 0; $i -le $d.GetUpperBound(0); $i++) {
$d[$i,0] = $i
}
for ($i = 0; $i -le $d.GetUpperBound(1); $i++) {
$d[0,$i] = $i
}
for ($i = 1; $i -le $d.GetUpperBound(0); $i++) {
for ($j = 1; $j -le $d.GetUpperBound(1); $j++) {
$cost = [Convert]::ToInt32((-not($String1[$i-1] -ceq $String2[$j-1])))
$min1 = $d[($i-1),$j] + 1
$min2 = $d[$i,($j-1)] + 1
$min3 = $d[($i-1),($j-1)] + $cost
$d[$i,$j] = [Math]::Min([Math]::Min($min1,$min2),$min3)
}
}
$distance = ($d[$d.GetUpperBound(0),$d.GetUpperBound(1)])
if ($NormalizeOutput) {
Write-Output (1 - ($distance) / ([Math]::Max($String1.Length,$String2.Length)))
}
else {
Write-Output $distance
}
}
catch {
Write-Warning $_.Exception.Message
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment