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(ValueFromPipeline = $true)] [string]$Command ) process { if ($AliasTipsHash -and $AliasTipsHash.Count -eq 0) { $AliasTipsHash = ConvertFrom-StringData -StringData $([System.IO.File]::ReadAllText($AliasTipsHashFile)) -Delimiter "|" } # If we can find the alias quickly, do so $Alias = $AliasTipsHash[$Command.Trim()] if ($Alias) { Write-Verbose "Quickly found alias inside of AliasTipsHash" return $Alias } # 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. $Regex = Get-CommandRegex $Command 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 } # Substitute commands using ExecutionContext if possible # Check if we have anything that has a $(...) if ($_.key -match $SimpleSubRegex -and ([boolean](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() $AliasTipsHashEvaluated[$NewKey] = $_.value } } } Clear-AliasTipsInternalASTResults Write-Verbose $($Aliases -Join ",") # Use the longest candiate $AliasCandidate = ($Aliases | Sort-Object -Descending -Property Length)[0] $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 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 = @{} # 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 $AliasTipsProxyFunctionRegex) { $CleanedCommand = ("$($matches['cmd'].TrimStart()) $($matches['params'])") | Format-Command if ($ProxyDef -match '\$args') { $Hash[$CleanedCommand + ' $args'] = $ProxyName } # quick alias $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}, [Parameter()] [switch]${Simple} ) process { if ($Simple) { $CleanCommand = $Command | Format-Command return "(" + ([Regex]::Escape($CleanCommand) -split " " -join "|") + ")" } # The parse is a bit naive... if ($Command -match $AliasTipsProxyFunctionRegexNoArgs) { # Clean up the command by removing extra delimiting whitespace and backtick preceding newlines $CommandString = ("$($matches['cmd'].TrimStart())") | Format-Command if ([string]::IsNullOrEmpty($CommandString)) { return "" } $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-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 '|' } function Get-EnvVariable { param ( [string]$VariableName ) [System.Environment]::GetEnvironmentVariable($VariableName) } # 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])" } } 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 Disable-AliasTips { [System.Environment]::SetEnvironmentVariable("ALIASTIPS_DISABLE", $true) } function Enable-AliasTips { [System.Environment]::SetEnvironmentVariable("ALIASTIPS_DISABLE", $false) } # Attempts to find an alias for a command string (ie. can consist of chained or nested aliases) function Find-Alias { param( [Parameter(Mandatory)][string]$Line ) $tokens = @() $ast = [System.Management.Automation.Language.Parser]::ParseInput($Line, [ref]$tokens, [ref]$null) $fastAlias = Find-AliasCommand ($tokens.Text -join " ") if (-not [string]::IsNullOrEmpty($fastAlias)) { Write-Verbose "Found alias without resorting to parsing" return $fastAlias } $queue = [System.Collections.ArrayList]::new() $extents = @(0, 0) $offset = 0 $aliased = $ast.ToString() foreach ($token in $tokens) { $kind = $token.Kind # Write-Host ($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 { $global:AliasTipsProxyFunctionRegex, $global:AliasTipsProxyFunctionRegexNoArgs = Get-CommandsRegex | Get-ProxyFunctionRegexes $AliasTipsHash = Get-Aliases $Value = $($AliasTipsHash.GetEnumerator() | ForEach-Object { if ($_.Key.Length -ne 0) { # Replaces \ with \\ "$($_.Key -replace "\\", "\\")|$($_.Value -replace "\\", "\\")" } }) Set-Content -Path $AliasTipsHashFile -Value $Value } # Store the original PSConsoleHostReadLine function when importing the module $global:AliasTipsOriginalPSConsoleHostReadLine = $function:PSConsoleHostReadLine 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 } } Register-EngineEvent -SourceIdentifier ([System.Management.Automation.PsEngineEvent]::Exiting) -Action { $function:PSConsoleHostReadLine = $global:AliasTipsOriginalPSConsoleHostReadLine } $AliasTipsHashFile = Initialize-EnvVariable "ALIASTIPS_HASH_PATH" "$([System.IO.Path]::Combine("$HOME", '.alias_tips.hash'))" Initialize-EnvVariable "ALIASTIPS_DISABLE" $false $AliasTipsHash = @{} $AliasTipsHashEvaluated = @{} |