ResolveAlias.psm1

#requires -version 3.0
## ResolveAlias Module v2.0
########################################################################################################################
## Version History
## 1.0 - First Version. "It worked on my sample script"
## 1.1 - Now it parses the $(...) blocks inside strings
## 1.2 - Some tweaks to spacing and indenting (I really gotta get some more test case scripts)
## 1.3 - I went back to processing the whole script at once (instead of a line at a time)
## Processing a line at a time makes it impossible to handle Here-Strings...
## I'm considering maybe processing the tokens backwards, replacing just the tokens that need it
## That would mean I could get rid of all the normalizing code, and leave the whitespace as-is
## 1.4 - Now resolves parameters too
## 1.5 - Fixed several bugs with command resolution (the ? => ForEach-Object problem)
## - Refactored the Resolve-Line filter right out of existence
## - Created a test script for validation, and
## 1.6 - Added resolving parameter ALIASES instead of just short-forms
## 1.7 - Minor tweak to make it work in CTP3
## 2.0 - Modularized and v3 compatible
## 2.1 - Added options to Expand-Alias to support generating scripts from your history buffer'
## 2.2 - Update to PowerShell 3 -- add -AllowedModule to Resolve-Command (which)
## 2.3 - Update (for PowerShell 3 only)
## * *TODO:* Put back the -FullPath option to resolve cmdlets with their snapin path
## * *TODO:* Add an option to put #requires statements at the top for each snapin used
########################################################################################################################
Set-StrictMode -Version latest
function Resolve-Command {
  #.Synopsis
  # Determine which command is being referred to by the Name
  [CmdletBinding()]
  param( 
    # The name of the command to be resolved
    [Parameter(Mandatory=$true)]
    [String]$Name, 

    # The name(s) of the modules from which commands are allowed (defaults to modules that are already imported). Pass * to allow any commands.
    [String[]]$AllowedModule=$(@(Microsoft.PowerShell.Core\Get-Module | Select -Expand Name) + 'Microsoft.PowerShell.Core'),

    # A list of commands that are allowed even if they're not in the AllowedModule(s)
    [Parameter()]
    [string[]]$AllowedCommand
  )
  end {
    $Search = $Name -replace '(.)$','[$1]'
    # aliases, functions, cmdlets, scripts, executables, normal files
    $Commands = @(Microsoft.PowerShell.Core\Get-Command $Search -Module $AllowedModule -ErrorAction SilentlyContinue)
    if(!$Commands) {
      if($match = $AllowedCommand -match "^[^-\\]*\\*$([Regex]::Escape($Name))") {
        $OFS = ", "
        Write-Verbose "Commands is empty, but AllowedCommand ($AllowedCommand) contains $Name, so:"
        $Commands = @(Microsoft.PowerShell.Core\Get-Command $match)
      }
    }
    $cmd = $null
    
    if($Commands) {
      Write-Verbose "Commands $($Commands|% { $_.ModuleName + '\' + $_.Name })"

      if($Commands.Count -gt 1) {
        $cmd = @( $Commands | Where-Object { $_.Name -match "^$([Regex]::Escape($Name))" })[0]
      } else {
        $cmd = $Commands[0]
      }
    }

    if(!$cmd -and !$Search.Contains("-")) {
      $Commands = @(Microsoft.PowerShell.Core\Get-Command "Get-$Search" -ErrorAction SilentlyContinue -Module $AllowedModule | Where-Object { $_.Name -match "^Get-$([Regex]::Escape($Name))" })
      if($Commands) {
        if($Commands.Count -gt 1) {
          $cmd = @( $Commands | Where-Object { $_.Name -match "^$([Regex]::Escape($Name))" })[0]
        } else {
          $cmd = $Commands[0]
        }
      }
    }

    if(!$cmd -or $cmd.CommandType -eq "Alias") {
      if(($FullName = Microsoft.PowerShell.Utility\Get-Alias $Name -ErrorAction SilentlyContinue)) {
        if($FullName = $FullName.ResolvedCommand) {
          $cmd = Resolve-Command $FullName -AllowedModule $AllowedModule -AllowedCommand $AllowedCommand -ErrorAction SilentlyContinue
        }
      }
    }
    if(!$cmd) {
      if($PSBoundParameters.ContainsKey("AllowedModule")) {
        Write-Warning "No command '$Name' found in the allowed modules."
      } else {
        Write-Warning "No command '$Name' found in allowed modules. Expand-Alias defaults to only loaded modules, specify -AllowedModule `"*`" to allow ANY module"
      }     
      Write-Verbose "The current AllowedModules are: $($AllowedModule -join ', ')"
    }
    return $cmd
  }
}

function Protect-Script {
  #.Synopsis
  # Expands aliases and validates scripts, preventing embedded script and
  [CmdletBinding(ConfirmImpact="low",DefaultParameterSetName="Text")]
  param (
    # The script you want to expand aliases in
    [Parameter(Mandatory=$true, ParameterSetName="Text", Position=0)]
    [Alias("Text")]
    [string]$Script,

    # A list of modules that are allowed in the scripts we're protecting
    [Parameter(Mandatory=$true)]
    [string[]]$AllowedModule,

    # A list of commands that are allowed even if they're not in the AllowedModule(s)
    [Parameter()]
    [string[]]$AllowedCommand,

    # A list of variables that are allowed even if they're not in the AllowedModule(s)
    [Parameter()]
    [string[]]$AllowedVariable
  )

  $Script = Expand-Alias -Script:$Script -AllowedModule:$AllowedModule -AllowedCommand $AllowedCommand -AllowedVariable $AllowedVariable -WarningVariable ParseWarnings -ErrorVariable ParseErrors -ErrorAction SilentlyContinue
  foreach($e in $ParseErrors | Select-Object -Expand Exception | Select-Object -Expand Errors) {
    Write-Warning $(if($e.Extent.StartLineNumber -eq $e.Extent.EndLineNumber) {
        "{0} (Line {1}, Char {2}-{2})" -f $e.Message, $e.Extent.StartLineNumber, $e.Extent.StartColumnNumber, $e.Extent.EndColumnNumber  
      } else {
        "{0} (l{1},c{2} - l{3},c{4})"  -f $e.Message, $e.Extent.StartLineNumber, $e.Extent.StartColumnNumber, $e.Extent.EndLineNumber, $e.Extent.EndColumnNumber  
      })
  }

  if(![String]::IsNullOrWhiteSpace($Script)) {

    [string[]]$Commands = $AllowedCommand + (Microsoft.PowerShell.Core\Get-Command -Module:$AllowedModule | % { "{0}\{1}" -f $_.ModuleName, $_.Name})
    [string[]]$Variables = $AllowedVariable + (Microsoft.PowerShell.Core\Get-Module $AllowedModule | Select-Object -Expand ExportedVariables | Select-Object -Expand Keys)

    try {
      [ScriptBlock]::Create($Script).CheckRestrictedLanguage($Commands, $Variables, $false)
      return $Script
    } catch [System.Management.Automation.ParseException] {
      $global:ProtectionError = $_.Exception.GetBaseException().Errors

      foreach($e in $ProtectionError) {
        Write-Warning $(if($e.Extent.StartLineNumber -eq $e.Extent.EndLineNumber) {
            "{0} (Line {1}, Char {2}-{2})" -f $e.Message, $e.Extent.StartLineNumber, $e.Extent.StartColumnNumber, $e.Extent.EndColumnNumber  
          } else {
            "{0} (l{1},c{2} - l{3},c{4})"  -f $e.Message, $e.Extent.StartLineNumber, $e.Extent.StartColumnNumber, $e.Extent.EndLineNumber, $e.Extent.EndColumnNumber  
          })
      }
    } catch {
      $global:ProtectionError = $_
      Write-Warning $_      
    }
  }

}

function Expand-Alias {
  #.Synopsis
  # Expands aliases (optionally adding the fully qualified module name) and short parameters
  #.Description
  # Expands all aliases (recursively) to actual functions/cmdlets/executables
  # Expands all short-form parameter names to their full versions
  # Works on files or strings, and can expand "inplace" on a file
  #.Example
  # Expand-Alias -Script "gcm help"
  #
   [CmdletBinding(ConfirmImpact="low",DefaultParameterSetName="Files")]
   param (
      # The script file you want to expand aliases in
      [Parameter(Position=0, Mandatory=$true, ValueFromPipeline=$true, ParameterSetName="Files")]
      [Alias("FullName","PSChildName","PSPath")]
      [IO.FileInfo]$File,

      # Enables replacing aliases in-place in files instead of into a new file
      [Parameter(ParameterSetName="Files")] 
      [Switch]$InPlace,

      # The script you want to expand aliases in
      [Parameter(Mandatory=$true, ValueFromPipeline=$true, ParameterSetName="Text")]
      [Alias("Text")]
      [string]$Script,

      # The History ID's of commands you want to expand (this supports generating scripts from previous commands, see examples)
      [Parameter(Position=0, Mandatory=$false, ValueFromPipeline=$true, ParameterSetName="History")]
      [Alias("Id")]
      [Int[]]$History,

      # The count of previous commands (from get-history) to expand (see examples)
      [Parameter(Mandatory=$true, ValueFromPipeline=$true, ParameterSetName="HistoryCount")]
      [Int]$Count,

      # Allows you to specify a list of modules that are allowed in the scripts we're resolving.
      # Defaults to the currently loaded modules, but specify "*" to allow ANY module.
      [string[]]$AllowedModule=$(@(Microsoft.PowerShell.Core\Get-Module | Select -Expand Name) + 'Microsoft.PowerShell.Core'),

      # A list of commands that are allowed even if they're not in the AllowedModule(s)
      [Parameter()]
      [string[]]$AllowedCommand,

      # A list of variables that are allowed even if they're not in the AllowedModule(s)
      [Parameter()]
      [string[]]$AllowedVariable,

      # Allows you to leave the namespace (module name) off of commands
      # By default Expand-Alias will expand 'gc' to 'Microsoft.PowerShell.Management\Get-Content'
      # If you specify the Unqualified flag, it will expand to just 'Get-Content' instead.
      [Parameter()]
      [Switch]$Unqualified
   )
   begin {
      Write-Debug $PSCmdlet.ParameterSetName
   }
   process {
      
      switch( $PSCmdlet.ParameterSetName ) {
         "Files" {
            if($File -is [System.IO.FileInfo]){
               $Script = (Get-Content $File -Delim ([char]0))            
            }
         }
         "History" {
            $Script = (Get-History -Id $History | Select-Object -Expand CommandLine) -Join "`n"
         }
         "HistoryCount" {
            $Script = (Get-History -Count $Count | Select-Object -Expand CommandLine) -Join "`n"
         }
         "Text" {}
         default { throw "ParameterSet: $($PSCmdlet.ParameterSetName)" }
      }

      $ParseError = $null
      $Tokens = $null
      $AST = [System.Management.Automation.Language.Parser]::ParseInput($Script, [ref]$Tokens, [ref]$ParseError)
      $Global:Tokens = $Tokens

      if($ParseError) {
         foreach($PEr in $ParseError) { 
            $PSCmdlet.WriteError( 
               (New-Object System.Management.Automation.ErrorRecord (
                  New-Object System.Management.Automation.ParseException $PEr),
                  "Unexpected Exception", "InvalidResult", $_) )
         }
         Write-Warning "There was an error parsing script (See above). We cannot expand aliases until the script parses without errors."
         return
      }
      $lastCommand = $Tokens.Count + 1
      :token for($t = $Tokens.Count -1; $t -ge 0; $t--) {
         Write-Verbose "Token $t of $($Tokens.Count)"
         $token = $Tokens[$t]
         switch -Regex ($token.Kind) {
            "Generic|Identifier" {
                if($token.TokenFlags -eq 'CommandName') {
                   if($lastCommand -ne $t) {
                      $OFS = ", "
                      Write-Verbose "Resolve-Command -Name $Token.Text -AllowedModule $AllowedModule -AllowedCommand @($AllowedCommand)"
                      $Command = Resolve-Command -Name $Token.Text -AllowedModule $AllowedModule -AllowedCommand $AllowedCommand
                      if(!$Command) { return $null }
                   }
                   Write-Verbose "Unqualified? $Unqualified"
                   if(!$Unqualified -and $Command.ModuleName) { 
                      $CommandName = '{0}\{1}' -f $Command.ModuleName, $Command.Name
                   } else {
                      $CommandName = $Command.Name
                   }
                   $Script = $Script.Remove( $Token.Extent.StartOffset, ($Token.Extent.EndOffset - $Token.Extent.StartOffset)).Insert( $Token.Extent.StartOffset, $CommandName )
                }
            }
            "Parameter" {
               # Figure out which command they're talking about
               Write-Verbose "lastCommand: $lastCommand ($t)"
               if($lastCommand -ge $t) {
                  for($c = $t; $c -ge 0; $c--) {
                    Write-Verbose "c: $($Tokens[$c].Text) ($($Tokens[$c].Kind) and $($Tokens[$c].TokenFlags))"

                     if(("Generic","Identifier" -contains $Tokens[$c].Kind) -and $Tokens[$c].TokenFlags -eq "CommandName" ) {
                        Write-Verbose "Resolve-Command -Name $($Tokens[$c].Text) -AllowedModule $AllowedModule -AllowedCommand $AllowedCommand"
                        $Command = Resolve-Command -Name $Tokens[$c].Text -AllowedModule $AllowedModule -AllowedCommand $AllowedCommand
                        if($Command) {
                          Write-Verbose "Command: $($Tokens[$c].Text) => $($Command.Name)"
                        }

                        $global:RCommand = $Command
                        if(!$Command) { return $null }
                        $lastCommand = $c
                        break
                     }
                  }
               }
            
               $short = "^" + $token.ParameterName
               $parameters = @($Command.ParameterSets | Select-Object -ExpandProperty Parameters | Where-Object {
                                $_.Name -match $short -or $_.Aliases -match $short
                             } | Select-Object -Unique)

               Write-Verbose "Parameters: $($parameters | Select -Expand Name)"
               Write-Verbose "Parameters: $($Command.ParameterSets | Select-Object -ExpandProperty Parameters | Select -Expand Name) | ? Name -match $short"
               if($parameters.Count -ge 1) {
                  # if("Verbose","Debug","WarningAction","WarningVariable","ErrorAction","ErrorVariable","OutVariable","OutBuffer","WhatIf","Confirm" -contains $parameters[0].Name ) {
                  # $Script = $Script.Remove( $Token.Extent.StartOffset, ($Token.Extent.EndOffset - $Token.Extent.StartOffset))
                  # continue
                  # }
                  if($parameters[0].ParameterType -ne [System.Management.Automation.SwitchParameter]) {
                     if($Tokens.Count -ge $t -and ("Parameter","Semi","NewLine" -contains $Tokens[($t+1)].Kind)) {
                        ## $Tokens[($t+1)].Kind -eq "Generic" -and $Tokens[($t+1)].TokenFlags -eq 'CommandName'
                        Write-Warning "No value for parameter: $($parameters[0].Name), the next token is a $($Tokens[($t+1)].Kind) (Flags: $($Tokens[($t+1)].TokenFlags))"
                        $Script = ""
                        break token
                     }
                  }
                  $Script = $Script.Remove( $Token.Extent.StartOffset, ($Token.Extent.EndOffset - $Token.Extent.StartOffset)).Insert( $Token.Extent.StartOffset, "-$($parameters[0].Name)" )
               } else {
                  Write-Warning "Rejecting Non-Parameter: $($token.ParameterName)"
                  # $Script = $Script.Remove( $Token.Extent.StartOffset, ($Token.Extent.EndOffset - $Token.Extent.StartOffset))
                  $Script = ""
                  break token
               }
               continue
            }
         }
      }


      if($InPlace) {
        if([String]::IsNullOrWhiteSpace($Script)) {
          Write-Warning "Script is empty after Expand-Alias, File ($File) not updated"
        } else {
          Set-Content -Path $File -Value $Script
        }
      } else {
        if([String]::IsNullOrWhiteSpace($Script)) {
          return
        } else {
          return $Script
        }
      }
   }
}


Set-Alias Resolve-Alias Expand-Alias
Export-ModuleMember -Function * -Alias *