alias-tips.psm1
using namespace System.Management.Automation using namespace System.Collections.ObjectModel function Clear-AliasTipsInternalASTResults { Clear-Variable AliasTipsInternalASTResults_* -Scope Global } # Attempts to find an alias for a singular command function Find-AliasCommand { param( [Parameter(Mandatory, ValueFromPipeline = $true)] [string]$Command ) begin { if ($AliasTipsHash -and $AliasTipsHash.Count -eq 0) { $AliasTipsHash = ConvertFrom-StringData -StringData $([System.IO.File]::ReadAllText($AliasTipsHashFile)) -Delimiter "|" } } process { # If we can find the alias quickly, do so $Alias = $AliasTipsHash[$Command.Trim()] if (-not [string]::IsNullOrEmpty($Alias)) { Write-Verbose "Quickly found alias inside of AliasTipsHash" return $Alias | Format-Command } # TODO check if it is an alias, expand it back out to check if there is a better alias # We failed to find the alias in the hash, instead get the executed command, and attempt to generate a regex for it. # First we need to ensure we have generated required regexes Find-RegexThreadJob # Generate a regex that searches through our alias hash, and checks if it matches as an alias for our command $Regex = Get-CommandRegex $Command # Write-Host $Regex if ([string]::IsNullOrEmpty($Regex)) { return "" } $SimpleSubRegex = "$([Regex]::Escape($($Command | Format-Command).Split(" ")[0]))[^`$`n]*\`$" $Aliases = @("") Write-Verbose "`n$Regex`n`n$SimpleSubRegex`n" # Create a new AliasHash with evaluated expression $AliasTipsHashEvaluated = $AliasTipsHash.Clone() $AliasTipsHash.GetEnumerator() | ForEach-Object { # Only reasonably evaluate any commands that match the one we are searching for if ($_.key -match $Regex) { $Aliases += ,($_.Key, $_.Value) } # Substitute commands using ExecutionContext if possible # Check if we have anything that has a $(...) if ($_.key -match $SimpleSubRegex -and ((Initialize-EnvVariable "ALIASTIPS_FUNCTION_INTROSPECTION" $false) -eq $true)) { $NewKey = Format-CommandFromExecutionContext($_.value) if (-not [string]::IsNullOrEmpty($NewKey) -and $($NewKey -replace '\$args', '') -match $Regex) { $Aliases += ,($($NewKey -replace '\$args', '').Trim(), $_.Value) $AliasTipsHashEvaluated[$NewKey] = $_.value } } } Clear-AliasTipsInternalASTResults # Sort by which alias removes the most, then if they both shorten by same amount, choose the shorter alias $Aliases = @(@($Aliases | Where-Object { $null -ne $_[0] -and $null -ne $_[1] }) | Sort-Object -Property @{Expression = { - ($_[0]).Length } }, @{Expression = { ($_[1]).Length} }) # foreach ($pair in $Aliases) { # Write-Host "($($pair[0]), $($pair[1]))" # } # Use the longest candiate, if tied use shorter alias # -- TODO? this is my opinionated way since it results in most coverage (one long alias is better than two combined shorter aliases), $AliasCandidate = ($Aliases)[0][0] Write-Verbose "Alias Candidate Chosen: $AliasCandidate" $Alias = "" if (-not [string]::IsNullOrEmpty($AliasCandidate)) { $Remaining = "$($Command)" $CleanAlias = "$($AliasCandidate)" | Format-Command $AttemptSplit = $CleanAlias -split " " $AttemptSplit | ForEach-Object { [Regex]$Pattern = [Regex]::Escape("$_") $Remaining = $Pattern.replace($Remaining, "", 1) } if (-not $Remaining) { $Alias = ($AliasTipsHashEvaluated[$AliasCandidate]) | Format-Command } if ($AliasTipsHashEvaluated[$AliasCandidate + ' $args']) { # TODO: Sometimes superflous args aren't at the end... Fix this. $Alias = ($AliasTipsHashEvaluated[$AliasCandidate + ' $args'] + $Remaining) | Format-Command } if ($Alias -ne $Command) { return $Alias } } } } function Find-RegexThreadJob { if ($null -ne $script:AliasTipsProxyFunctionRegex -and $null -ne $script:AliasTipsProxyFunctionRegexNoArgs) { return } $existingJob = Get-Job -Name "FindAliasTipsJob" -ErrorAction SilentlyContinue | Select-Object -Last 1 if ($null -ne $existingJob) { $existingJob = Wait-Job -Job $existingJob } else { $job = Start-RegexThreadJob $existingJob = Wait-Job -Job $job } $result = Receive-Job -Job $existingJob -Wait -AutoRemoveJob # this is a regex to find all commands, not just aliases/functions $script:AliasTipsProxyFunctionRegex, $script:AliasTipsProxyFunctionRegexNoArgs = $result } function Format-Command { param( [Parameter(Position = 0, ValueFromPipeline = $true, Mandatory = $true)][string]${Command} ) process { if ([string]::IsNullOrEmpty($Command)) { return $Command } $tokens = @() [void][System.Management.Automation.Language.Parser]::ParseInput($Command, [ref]$tokens, [ref]$null) return ($tokens.Text -join " " -replace '\s*\r?\n\s*', ' ').Trim() } } $script:AUTOMATIC_VARIBLES_TO_SUPRESS = @( '\$', '\?', '\^', '_', 'args', 'ConsoleFileName', 'EnabledExperimentalFeatures', 'Error', 'Event(Args|Subscriber)?', 'ExecutionContext', 'false', 'HOME', 'Host', 'input', 'Is(CoreCLR|Linux|MacOS|Windows){1}', 'LASTEXITCODE', 'Matches', # TODO? 'MyInvocation', 'NestedPromptLevel', 'null', 'PID', 'PROFILE', 'PSBoundParameters', # TODO? 'PSCmdlet', 'PSCommandPath', # TODO? 'PSCulture', 'PSDebugContext', 'PSEdition', 'PSHOME', 'PSItem', 'PSScriptRoot', 'PSSenderInfo', 'PSUICulture', 'PSVersionTable', 'PWD', 'Sender', 'ShellId', 'StackTrace', 'switch', 'this', 'true' ) -Join '|' # Finds the command based on the alias and replaces $(...) if possible function Format-CommandFromExecutionContext { param( [Parameter(Mandatory)][string]${Alias} ) # Get the original definition $Def = Get-Item -Path Function:\$Alias | Select-Object -ExpandProperty 'Definition' # Find variables we need to resolve, ie $MainBranch $VarsToResolve = @("") $ReconstructedCommand = "" if ($Def -match $AliasTipsProxyFunctionRegexNoArgs) { $ReconstructedCommand = ("$($matches['cmd'].TrimStart()) $($matches['params'])") | Format-Command if ($args -match '\$args') { $ReconstructedCommand += ' $args' } $($matches['params'] | Format-Command) -split " " | ForEach-Object { if ($_ -match '\$') { # Make sure it is not an automatic variable if ($_ -match "(\`$)($AUTOMATIC_VARIBLES_TO_SUPRESS)") { } else { $VarsToResolve += $_ -replace "[^$`n]*(?=\$)", "" } } } } else { return "" } $VarsReplaceHash = @{} Get-Variable AliasTipsInternalASTResults_* | ForEach-Object { if ($_.Value) { $VarsReplaceHash[$($_.Name -replace "AliasTipsInternalASTResults_", "")] = $_.Value } } # If there are vars to resolve, attempt to find them. if ($VarsToResolve) { $DefScriptBlock = [scriptblock]::Create($Def) $DefAst = $DefScriptBlock.Ast foreach ($Var in $VarsToResolve) { # Attempt to find the definition based on the ast # TODO: handle nested script blocks $FoundAssignment = $DefAst.Find({ $args[0] -is [System.Management.Automation.Language.VariableExpressionAst] -and $("$($args[0].Extent)" -eq "$Var") }, $false) if ($FoundAssignment -and -not $VarsReplaceHash[$Var]) { $CommandToEval = $($FoundAssignment.Parent -replace "[^=`n]*=", "").Trim() # Super naive LOL! Hopefully the command isn't destructive! $Evaluated = Invoke-Command -ScriptBlock $([scriptblock]::Create("$CommandToEval -ErrorAction SilentlyContinue")) if ($Evaluated) { $VarsReplaceHash[$Var] = $Evaluated Set-Variable -Name "AliasTipsInternalASTResults_$Var" -Value $Evaluated -Scope Global } } } $VarsReplaceHash.GetEnumerator() | ForEach-Object { if ($_.Value) { $ReconstructedCommand = $ReconstructedCommand -replace $([regex]::Escape($_.key)), $_.Value } } return $($ReconstructedCommand | Format-Command) } } # Return a hashtable of possible aliases function Get-Aliases { $Hash = @{} Find-RegexThreadJob # generate aliases for commands aliases created via native PowerShell functions $proxyAliases = Get-Item -Path Function:\ foreach ($alias in $proxyAliases) { $f = Get-Item -Path Function:\$alias $ProxyName = $f | Select-Object -ExpandProperty 'Name' $ProxyDef = $f | Select-Object -ExpandProperty 'Definition' # validate there is a command if ($ProxyDef -match $script:AliasTipsProxyFunctionRegex) { $CleanedCommand = ("$($matches['cmd'].TrimStart()) $($matches['params'])") | Format-Command if ($ProxyDef -match '\$args') { # Use the shorter of two if we already have hashed this command if ($Hash.ContainsKey($CleanedCommand + ' $args')) { if ($ProxyName.Length -lt $Hash[$CleanedCommand + ' $args'].Length) { $Hash[$CleanedCommand + ' $args'] = $ProxyName } } else { $Hash[$CleanedCommand + ' $args'] = $ProxyName } } # quick alias # use the shorter of two if we already have hashed this command if ($Hash.ContainsKey($CleanedCommand)) { if ($ProxyName.Length -lt $Hash[$CleanedCommand].Length) { $Hash[$CleanedCommand] = $ProxyName } } else { $Hash[$CleanedCommand] = $ProxyName } } } # generate aliases configured from the `Set-Alias` command Get-Alias | ForEach-Object { $aliasName = $_ | Select-Object -ExpandProperty 'Name' $aliasDef = $($_ | Select-Object -ExpandProperty 'Definition') | Format-Command $hash[$aliasDef] = $aliasName $hash[$aliasDef + ' $args'] = $aliasName } return $hash } function Get-CommandRegex { [CmdletBinding()] [OutputType([System.String])] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [string]${Command} ) process { # The parse is a bit naive... if ($Command -match $script:AliasTipsProxyFunctionRegexNoArgs) { # Clean up the command by removing extra delimiting whitespace and backtick preceding newlines $CommandString = ("$($matches['cmd'].TrimStart())") if ([string]::IsNullOrEmpty($CommandString)) { return "" } $CommandString = $CommandString | Format-Command $ReqParams = $($matches['params']) -split " " $ReqParamRegex = "(" + ($ReqParams.ForEach({ "$([Regex]::Escape($_.Trim()))(\s|``\r?\n)*" }) -join '|') + ")*" # Enable sensitive case (?-i) # Begin anchor (^|[;`n]) # Whitespace (\s*) # Any Command (?<cmd>$CommandString) # Whitespace (\s|``\r?\n)* # Req Parameters (?<params>$ReqParamRegex) # Whitespace (\s|``\r?\n)* # End Anchor ($|[|;`n]) $Regex = "(?-i)(^|[;`n])(\s*)(?<cmd>$CommandString)(\s|``\r?\n)*(?<params>$ReqParamRegex)(\s|``\r?\n)*($|[|;`n])" return $Regex } } } function Get-EnvVariable { param ( [string]$VariableName ) [System.Environment]::GetEnvironmentVariable($VariableName) } function Initialize-EnvVariable { param ( [Parameter(Mandatory = $true, Position = 0)][string]$VariableName, [Parameter(Position = 1)][string]$DefaultValue ) $Var = Get-EnvVariable $VariableName $Var = if ($null -ne $Var) { $Var } else { $DefaultValue } Set-UnsetEnvVariable $VariableName $Var $Var } function Set-UnsetEnvVariable { [CmdletBinding(SupportsShouldProcess=$true)] param ( [string]$VariableName, [string]$Value ) # Check if the environment variable is already set if (-not [System.Environment]::GetEnvironmentVariable($VariableName)) { # Set the environment variable if($PSCmdlet.ShouldProcess($VariableName)){ [System.Environment]::SetEnvironmentVariable($VariableName, $Value) } } } function Start-RegexThreadJob { $existingJob = Get-Job -Name "FindAliasTipsJob" -ErrorAction SilentlyContinue | Select-Object -Last 1 if ($null -ne $existingJob) { $existingJob = Wait-Job -Job $existingJob } return Start-ThreadJob -Name "FindAliasTipsJob" -ScriptBlock { function Get-CommandsRegex { (Get-Command * | ForEach-Object { $CommandUnsafe = $_ | Select-Object -ExpandProperty 'Name' $Command = [Regex]::Escape($CommandUnsafe) # check if it has a file extensions if ($CommandUnsafe -match "(?<cmd>[^.\s]+)\.(?<ext>[^.\s]+)$") { $CommandWithoutExtension = [Regex]::Escape($matches['cmd']) return $Command, $CommandWithoutExtension } else { return $Command } }) -Join '|' } # The regular expression here roughly follows this pattern: # # <begin anchor><whitespace>*<command>(<whitespace><parameter>)*<whitespace>+<$args><whitespace>*<end anchor> # # The delimiters inside the parameter list and between some of the elements are non-newline whitespace characters ([^\S\r\n]). # In those instances, newlines are only allowed if they preceded by a non-newline whitespace character. # # Begin anchor (^|[;`n]) # Whitespace (\s*) # Any Command (?<cmd>) # Parameters (?<params>(([^\S\r\n]|[^\S\r\n]``\r?\n)+\S+)*) # $args Anchor (([^\S\r\n]|[^\S\r\n]``\r?\n)+\`$args) # Whitespace (\s|``\r?\n)* # End Anchor ($|[|;`n]) function Get-ProxyFunctionRegexes { param ( [Parameter(Mandatory, Position = 0, ValueFromPipeline = $true)][regex]${CommandPattern} ) process { [regex]"(^|[;`n])(\s*)(?<cmd>($CommandPattern))(?<params>(([^\S\r\n]|[^\S\r\n]``\r?\n)+\S+)*)(([^\S\r\n]|[^\S\r\n]``\r?\n)+\`$args)(\s|``\r?\n)*($|[|;`n])", [regex]"(^|[;`n])(\s*)(?<cmd>($CommandPattern))(?<params>(([^\S\r\n]|[^\S\r\n]``\r?\n)+\S+)*)(\s|``\r?\n)*($|[|;`n])" } } Get-CommandsRegex | Get-ProxyFunctionRegexes } } Start-RegexThreadJob | Out-Null function Disable-AliasTips { <# .SYNOPSIS Disables alias-tips .DESCRIPTION Disables alias-tips by setting $env:ALIASTIPS_DISABLE to $true .INPUTS None. This function does not accept any input. .OUTPUTS None. This function does not accept any input. .EXAMPLE PS> Disable-AliasTips #> [System.Environment]::SetEnvironmentVariable("ALIASTIPS_DISABLE", $true) } function Enable-AliasTips { <# .SYNOPSIS Enables alias-tips .DESCRIPTION Enables alias-tips by setting $env:ALIASTIPS_DISABLE to $false .INPUTS None. This function does not accept any input. .OUTPUTS None. This function does not accept any input. .EXAMPLE PS> Enable-AliasTips #> [System.Environment]::SetEnvironmentVariable("ALIASTIPS_DISABLE", $false) } function Find-Alias { <# .SYNOPSIS Finds an alias for a command string. .DESCRIPTION Finds an alias for a command string. Returns the original line if no aliases are found. .PARAMETER Line Specifies the line to find an alias for. .INPUTS [System.String](https://docs.microsoft.com/en-us/dotnet/api/system.string) .OUTPUTS [System.String](https://docs.microsoft.com/en-us/dotnet/api/system.string) .EXAMPLE PS> Find-Alias "git checkout master" .EXAMPLE PS> "git status" | Find-Alias #> param( [Parameter(Mandatory, ValueFromPipeline = $true)] [string]$Line ) process { if ($AliasTipsHash -and $AliasTipsHash.Count -eq 0) { $AliasTipsHash = ConvertFrom-StringData -StringData $([System.IO.File]::ReadAllText($AliasTipsHashFile)) -Delimiter "|" } $tokens = @() $ast = [System.Management.Automation.Language.Parser]::ParseInput($Line, [ref]$tokens, [ref]$null) $queue = [System.Collections.ArrayList]::new() $extents = @(0, 0) $offset = 0 $aliased = $ast.ToString() foreach ($token in $tokens) { $kind = $token.Kind Write-Verbose "$(($kind, "'$($token.Text)'" , $token.Extent.StartOffset, $token.Extent.EndOffset))" if ('Generic', 'Identifier', 'HereStringLiteral', 'Parameter', 'StringLiteral' -contains $kind) { if ($queue.Count -eq 0) { $queue += $token.Text $extents = @($token.Extent.StartOffset, $token.Extent.EndOffset) } else { $queue[-1] = "$($queue[-1]) $($token.Text)" $extents = @($extents[0], $token.Extent.EndOffset) } } else { # When we finish the current token back-alias it if ($queue.Count -gt 0) { $alias = Find-AliasCommand $queue[-1] if (-not [string]::IsNullOrEmpty($alias)) { $saved = $queue[-1].Length - $alias.Length $newleft = $extents[0] + $offset $newright = $extents[1] + $offset $aliased = "$(if ($newLeft -le 0) {''} else {$aliased.Substring(0, $newLeft)})$alias$(if ($newright -ge $aliased.Length) {''} else {$aliased.Substring($newright)})" $offset -= $saved } } # Reset the queue $queue = [System.Collections.ArrayList]::new() $extents = @(0, 0) if ('HereStringExpandable', 'StringExpandable' -contains $kind) { $ntokens = $token.NestedTokens if ($ntokens.Length -eq 0) { continue } $nqueue = [System.Collections.ArrayList]::new() $nextents = @(0, 0) foreach ($ntoken in $ntokens) { $nkind = $ntoken.Kind # Write-Host ("`t", $nkind, "'$($ntoken.Text)'" , $ntoken.Extent.StartOffset, $ntoken.Extent.Endoffset) if ('Generic', 'Identifier', 'HereStringLiteral', 'Parameter', 'StringLiteral' -contains $nkind) { if ($nqueue.Count -eq 0) { $nqueue += $ntoken.Text $nextents = @($ntoken.Extent.StartOffset, $ntoken.Extent.EndOffset) } else { $nqueue[-1] = "$($nqueue[-1]) $($ntoken.Text)" $nextents = @($nextents[0], $ntoken.Extent.EndOffset) } } else { # When we finish the current token back-alias it if ($nqueue.Count -gt 0) { $alias = Find-AliasCommand $nqueue[-1] if (-not [string]::IsNullOrEmpty($alias)) { $saved = $nqueue[-1].Length - $alias.Length $newleft = $nextents[0] + $offset $newright = $nextents[1] + $offset $aliased = "$(if ($newLeft -le 0) {''} else {$aliased.Substring(0, $newLeft)})$alias$(if ($newright -ge $aliased.Length) {''} else {$aliased.Substring($newright)})" $offset -= $saved } } # Reset the queue $nqueue = [System.Collections.ArrayList]::new() $nextents = @(0, 0) } } } } } $aliased.Trim() } } function Find-AliasTips { <# .SYNOPSIS Finds alias-tips for the current shell context. .DESCRIPTION Finds alias-tips for the current shell context. This command should be run everytime aliases are updated or changed. It caches the expensive operation to a pipe delimited file in the `$env:AliasTipsHashFile` location. By default this location is at `$HOME/.alias_tips.hash`. .EXAMPLE PS> Find-AliasTips #> $AliasTipsHash = Get-Aliases $Value = $($AliasTipsHash.GetEnumerator() | ForEach-Object { if ($_.Key.Length -ne 0) { # Replaces \ with \\ "$($_.Key -replace "\\", "\\")|$($_.Value -replace "\\", "\\")" } }) $script:AliasTipsProxyFunctionRegex, $script:AliasTipsProxyFunctionRegexNoArgs = $null $jobs = Get-Job -Name "FindAliasTipsJob" -ErrorAction SilentlyContinue if ($null -ne $jobs) { foreach ($job in $jobs) { Stop-Job -Job $job Remove-Job -Job $job } } Start-RegexThreadJob Set-Content -Path $AliasTipsHashFile -Value $Value } # Store the original PSConsoleHostReadLine function when importing the module $script:AliasTipsOriginalPSConsoleHostReadLine = Get-Item Function:\PSConsoleHostReadLine -ErrorAction SilentlyContinue function PSConsoleHostReadLine { ## Get the execution status of the last accepted user input. ## This needs to be done as the first thing because any script run will flush $?. $lastRunStatus = $? ($Line = [Microsoft.PowerShell.PSConsoleReadLine]::ReadLine($host.Runspace, $ExecutionContext, $lastRunStatus)) if ([System.Environment]::GetEnvironmentVariable("ALIASTIPS_DISABLE") -eq [string]$true) { return } # split line into multiple commands if possible $alias = Find-Alias $Line if (-not [string]::IsNullOrEmpty($alias) -and ($alias | Format-Command) -ne ($Line | Format-Command)) { $tip = (Initialize-EnvVariable "ALIASTIPS_MSG" "Alias tip: {0}") -f $alias $vtTip = (Initialize-EnvVariable "ALIASTIPS_MSG_VT" "`e[033mAlias tip: {0}`e[m") -f $alias if ($tip -eq "") { Write-Warning "Error formatting ALIASTIPS_MSG" } if ($vtTip -eq "") { Write-Warning "Error formatting ALIASTIPS_MSG_VT" } $host.UI.SupportsVirtualTerminal ? $vtTip : $tip | Out-Host } } $DEFAULT_PSConsoleHostReadLine = { [System.Diagnostics.DebuggerHidden()] param() ## Get the execution status of the last accepted user input. ## This needs to be done as the first thing because any script run will flush $?. $lastRunStatus = $? Microsoft.PowerShell.Core\Set-StrictMode -Off [Microsoft.PowerShell.PSConsoleReadLine]::ReadLine($host.Runspace, $ExecutionContext, $lastRunStatus) } $MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = { if ($null -eq $script:AliasTipsOriginalPSConsoleHostReadLine) { $script:AliasTipsOriginalPSConsoleHostReadLine = $DEFAULT_PSConsoleHostReadLine } $toFixStr = "Set-Item Function:\PSConsoleHostReadLine -Value `$AliasTipsOriginalPSConsoleHostReadLine" @" `e[1;31mRemoved module alias-tips!`e[m `e[36mTo restore your PSReadline, run:`e[m $toFixStr `e[36mIt has been copied into your clipboard for your convenience`e[m "@ | Out-Host Set-Clipboard -Value $toFixStr # TODO is there a way to restore this automagically?? Set-Item Function:\PSConsoleHostReadLine -Value $script:AliasTipsOriginalPSConsoleHostReadLine -Force } $AliasTipsHashFile = Initialize-EnvVariable "ALIASTIPS_HASH_PATH" "$([System.IO.Path]::Combine("$HOME", '.alias_tips.hash'))" Initialize-EnvVariable "ALIASTIPS_DISABLE" $false $AliasTipsHash = @{} $AliasTipsHashEvaluated = @{} $script:AliasTipsProxyFunctionRegex, $script:AliasTipsProxyFunctionRegexNoArgs = $null, $null |