# encoding: ascii
# api: powershell
# title: Expand-Alias
# description: Converts aliases and parameter shortcuts in scripts to make them more portable.  Now resolves command names to include the module (make sure you load modules you need), resolves parameter aliases, etc. (Works in PowerShell 3 CTP1 too).
# version: 2.3
# type: module
# author: Joel Bennett
# license: CC0
# function: Resolve-Command
# x-poshcode-id: 4209
# x-archived: 2013-06-25T00:48:27
# x-published: 2013-06-19T18:00:00
#
#
#requires -version 2.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 = 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

  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 {
      $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 = (Get-History -Count 1).Id,

      #  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($er in $ParseError) { Write-Error $er }
         throw "There was an error parsing script (See above). We cannot expand aliases until the script parses without errors."
      }
      $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 *
