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 -ne $Line.Trim()) {
    $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 = @{}