# encoding: ascii
# api: powershell
# title: Get-Dependency
# description: ##############################################################################
# version: 0.1
# type: function
# author: Josh Einstein
# license: CC0
# function: Get-PSToken
# x-poshcode-id: 5848
# x-archived: 2016-01-27T04:30:05
# x-published: 2016-05-05T16:45:00
#
# #.SYNOPSIS
# Calculates the dependencies of a script file, block of PowerShell code, or
# an open PowerShell ISE document.
# # ##############################################################################
#
##############################################################################
#.AUTHOR
# Josh Einstein
# Einstein Technologies, LLC
##############################################################################
$BuiltInAliases = @{}
$BuiltInCmdlets = @{}
$BuiltInFunctions = @{}
$BuiltInVariables = @{}
##############################################################################
#.SYNOPSIS
# Gets a list of tokenized strings from the specified PowerShell code sample
# which can be used for dependency analysis or other unique purposes.
#
#.DESCRIPTION
# This command will use the currently active PowerShell ISE editor tab if the
# current shell is PowerShell ISE and a block of code was not provided to
# the command.
#
#.PARAMETER Type
# If specified, returns only PSTokens of the specified type(s).
#
#.PARAMETER Token
# If specified, returns only PSTokens that contain the specified text.
#
#.PARAMETER Text
# The block of text to tokenize. If this parameter is not specified, and the
# current shell is PowerShell ISE, the current editor tab's text will be used
# otherwise, no output is returned.
#
#.PARAMETER Path
# Specifies the path to an item. Wildcards are permitted.
#
#.PARAMETER LiteralPath
# Specifies the path to an item. Unlike Path, the value of LiteralPath is
# used exactly as it is typed. No characters are interpreted as wildcards.
# If the path includes escape characters, enclose it in single quotation marks.
# Single quotation marks tell Windows PowerShell not to interpret any
# characters as escape sequences.
#
#.PARAMETER Lines
# If specified, returns only the tokens that are on one of the line numbers
# specified. This is useful for doing a two-pass check as in the first example
# which aims to list function declarations which consist of two related tokens,
# the keyword containing the word "function" and the command argument containing
# the function name. It is assumed they will both be on the same line.
#
#.EXAMPLE
# $FunctionLines = Get-PSToken -Type Keyword -Token function | %{ $_.StartLine }
# $FunctionNames = Get-PSToken -Type CommandArgument | ?{ $FunctionLines -contains $_.StartLine }
#
#.LINK
# Get-Dependency
#
#.RETURNVALUE
# A collection of PSTokens parsed from the source code.
##############################################################################
function Get-PSToken {
[CmdletBinding(DefaultParameterSetName='Selection')]
param(
[Parameter(Position=1)]
[String[]]$Type,
[Parameter(Position=2)]
[String[]]$Token,
[Parameter(ParameterSetName='Path', Mandatory=$true, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)]
[String[]]$Path,
[Alias("PSPath")]
[Parameter(ParameterSetName='LiteralPath', Mandatory=$true, ValueFromPipelineByPropertyName=$true)]
[String[]]$LiteralPath,
[Parameter()]
[Int32[]]$Lines
)
process {
if ($PSCmdlet.ParameterSetName -eq 'Selection') {
if (-not $PSISE) { throw 'The Selection parameter set is not valid outside of the PowerShell ISE.' }
if (-not $PSISE.CurrentOpenedFile) { throw 'There is no file currently opened.' }
if (-not $PSISE.CurrentOpenedFile.IsSaved) { throw 'Please save the currently active document first.' }
if ($PSISE.CurrentOpenedFile.IsUntitled) { throw 'Please save the currently active document first.' }
$ResolvedPaths = @(Resolve-Path -LiteralPath $PSISE.CurrentOpenedFile.FullPath)
}
elseif ($PSCmdlet.ParameterSetName -match '^(Literal)?Path$') {
switch ($PSCmdlet.ParameterSetName) {
Path { $ResolvedPaths = @(Resolve-Path -Path $Path) }
LiteralPath { $ResolvePathArgs = @(Resolve-Path -LiteralPath $LiteralPath) }
}
}
# Delegate path expansion to Resolve-Path
foreach ($ResolvedPath in $ResolvedPaths) {
$ScriptContent = Get-Content $ResolvedPath
# Call the PSParser to tokenize!
$ParserErrors = [System.Management.Automation.PSParseError[]]@()
$ParserTokens = [System.Management.Automation.PSParser]::Tokenize($ScriptContent, [Ref]$ParserErrors)
# If there were any errors, write them out as warnings
for ($i=0; $i -lt $ParserErrors.Length; $i++) {
$ParserError = $ParserErrors[$i]
if (-not $ParserError.Token) { Write-Warning $ParserError.Message }
else { Write-Warning "($($ParserError.Token.StartLine), $($ParserError.Token.StartColumn)) $($ParserError.Message)" }
}
if (-not $ParserTokens.Count) { return }
# Filter the output
if ($Type.Length) { $ParserTokens = @($ParserTokens | ?{ $Type -contains $_.Type }) } # Filter By Type
if ($Token.Length) { $ParserTokens = @($ParserTokens | ?{ $Token -contains $_.Content }) } # Filter By Content
if ($Lines.Length) { $ParserTokens = @($ParserTokens | ?{ $Lines -contains $_.StartLine }) } # Filter By Line
if (-not $ParserTokens.Count) { return }
# Return the tokens, adding a Path property to each one
# that points back to the script or editor file
# note that text blocks will have a null path
$ParserTokens | Add-Member -PassThru NoteProperty Script (Split-Path -Leaf $ResolvedPath)
}
} # process
}
##############################################################################
#.SYNOPSIS
# Calculates the dependencies of a script file, block of PowerShell code, or
# an open PowerShell ISE document.
#
#.DESCRIPTION
# Before deploying a script or module, it is important to ensure that any
# external dependencies are resolved otherwise code that runs fine on your
# machine will bomb on someone else's. This function will scan a single
# level of dependencies from the specified script and by default returns
# any dependency that is 1) not a part of the built-in PowerShell command
# and variable configuration and 2) not defined in the script being analyzed.
# You can override this behavior and include these dependencies with the
# Force parameter.
#
#.PARAMETER Path
# Specifies the path to an item. Wildcards are permitted.
#
#.PARAMETER LiteralPath
# Specifies the path to an item. Unlike Path, the value of LiteralPath is
# used exactly as it is typed. No characters are interpreted as wildcards.
# If the path includes escape characters, enclose it in single quotation marks.
# Single quotation marks tell Windows PowerShell not to interpret any
# characters as escape sequences.
#
#.PARAMETER Text
# The block of text to tokenize. If this parameter is not specified, and the
# current shell is PowerShell ISE, the current editor tab's text will be used
# otherwise, no output is returned.
#
#.PARAMETER Unresolved
# When specified, only unresolved dependencies are returned.
#
#.PARAMETER Force
# When specified, all dependencies are included, even if they are known to
# be defined locally or in the default PowerShell configuration.
#
#.EXAMPLE
# Get-Dependency | Out-GridView
#
#.LINK
# Get-PSToken
#
#.RETURNVALUE
# An array of PSObjects containing information about the external dependencies.
##############################################################################
function Get-Dependency {
[CmdletBinding(DefaultParameterSetName='Selection')]
param(
[Parameter(ParameterSetName='Path', Position=1, Mandatory=$true, ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)]
[String[]]$Path,
[Alias("PSPath")]
[Parameter(ParameterSetName='LiteralPath', Mandatory=$true, ValueFromPipelineByPropertyName=$true)]
[String[]]$LiteralPath,
[Parameter()]
[Switch]$Unresolved,
[Parameter()]
[Switch]$Force
)
begin {
$Dependencies = New-Object System.Collections.ArrayList
$LocalFunctions = @{}
$ImportedAliases = @{}
$ImportedCmdlets = @{}
$ImportedFunctions = @{}
##############################################################################
#.SYNOPSIS
# Strips off the scope modifier of an identifier so its name can be checked.
##############################################################################
filter Normalize-Identifier([String]$Name) {
if ($_ -is [System.Management.Automation.PSToken]) { $Name = $_.Content }
if ($_ -is [String]) { $Name = $_ }
$Name -replace '^$?(Script|Global|Local):',''
}
##############################################################################
#.SYNOPSIS
# Wraps calls to Get-PSToken, making sure to use the appropriate parameter set.
##############################################################################
function Get-PSTokenProxy([String[]]$Type,[String[]]$Token,[Int32[]]$Lines) {
$GetPSTokenArgs = @{}
if ($Type.Length) { $GetPSTokenArgs['Type'] = $Type }
if ($Token.Length) { $GetPSTokenArgs['Token'] = $Token }
if ($Lines.Length) { $GetPSTokenArgs['Lines'] = $Lines }
$ResolvedPaths | Get-PSToken @GetPSTokenArgs
}
##############################################################################
#.SYNOPSIS
# Rebuilds the cache of built-in command, alias, function, and variable names
# that are present in an unconfigured PowerShell session so that they can
# be skipped from dependency checking.
##############################################################################
function Discover-BuiltInCommands {
if ($BuiltInCmdlets.Count -eq 0) {
$BuiltInAliases.Clear()
$BuiltInCmdlets.Clear()
$BuiltInFunctions.Clear()
$BuiltInVariables.Clear()
$Posh = [PowerShell]::Create()
try {
[Void]$Posh.AddScript('Get-Command -CommandType Cmdlet,Function,Alias')
foreach($CommandInfo in $Posh.Invoke()) {
switch($CommandInfo.CommandType) {
Alias { $BuiltInAliases[$CommandInfo.Name] = $true }
Cmdlet { $BuiltInCmdlets[$CommandInfo.Name] = $true }
Function { $BuiltInFunctions[$CommandInfo.Name] = $true }
}
}
[Void]$Posh.AddScript('Get-Variable')
foreach($VariableInfo in $Posh.Invoke()) {
$BuiltInVariables[$VariableInfo.Name] = $true
}
# Note: PSISE won't appear as a built-in variable this way
# I decided I actually prefer it that way. I want to know if
# I accidentially couple a script to the ISE
$BuiltInVariables['this'] = $true
}
finally {
$Posh.Dispose()
}
} # if count 0
}
##############################################################################
#.SYNOPSIS
# Scans the source code for Import-Module statements and gathers up the
# module export information into hashtables.
##############################################################################
function Discover-ModuleImports {
$ImportedAliases.Clear()
$ImportedCmdlets.Clear()
$ImportedFunctions.Clear()
$TokenLines = @(Get-PSTokenProxy Command Import-Module | %{$_.StartLine})
if (-not $TokenLines.Length) { return } # nothing to do
$TokenNames = @(Get-PSTokenProxy CommandArgument -Lines $TokenLines | Normalize-Identifier)
if (-not $TokenNames.Length) { return } # nothing to do
# Find out which referenced modules are not imported
$MissingModules = @()
foreach ($TokenName in $TokenNames) {
if (-not (Get-Module $TokenName)) {
$MissingModules += $TokenName
}
}
if ($MissingModules.Length) {
$MenuItem1 = New-Object System.Management.Automation.Host.ChoiceDescription "&No - Do Not Import Modules"
$MenuItem2 = New-Object System.Management.Automation.Host.ChoiceDescription "&Yes - Import These Modules"
[System.Management.Automation.Host.ChoiceDescription[]]$MenuItems = @($MenuItem1,$MenuItem2)
$MenuCaption = "Imported Modules Not Loaded"
$MenuMessage = "One or more modules imported by the script are not currently loaded.`r`n" +
"This can lead to unresolved external references and missing`r`n" +
"information in the dependency report.`r`n" +
"`r`n" +
"You can attempt to temporarily import the following modules into the`r`n" +
"session by selection the option below.`r`n" +
"`r`n"
foreach ($MissingModule in $MissingModules) {
$MenuMessage += " Import-Module $MissingModule`r`n"
}
# Show the menu
# If the user picks choice Y then import the modules
# Because you can't import modules into the global scope from a lower scope,
# the imported module will not be visible from outside of the current scope.
$Response = $Host.UI.PromptForChoice($MenuCaption, $MenuMessage, $MenuItems, 0)
if ($Response = 1) {
foreach ($MissingModule in $MissingModules) {
# Using Invoke-Expression so this Import-Module command
# doesn't cause a screw-up when checking dependencies of
# Dependency.psm1. Gay huh...
Invoke-Expression ".{Import-Module $MissingModule -ErrorAction Continue}"
}
}
}
$ImportedModules = @(Get-Module $TokenNames -ListAvailable) + @(Get-Module $TokenNames)
foreach ($ImportedModule in $ImportedModules) {
$ImportedModule.ExportedAliases.Values | %{ $ImportedAliases[$_.Name] = $_ }
$ImportedModule.ExportedCmdlets.Values | %{ $ImportedCmdlets[$_.Name] = $_ }
$ImportedModule.ExportedFunctions.Values | %{ $ImportedFunctions[$_.Name] = $_ }
}
}
##############################################################################
#.SYNOPSIS
# Scans the source code for function and filter keywords that define local
# functions that should not be treated as external dependencies.
##############################################################################
function Discover-LocalFunctions {
$LocalFunctions.Clear()
# Gather information about defined functions
$TokenLines = @(Get-PSTokenProxy Keyword function,filter | %{$_.StartLine})
if (-not $TokenLines.Length) { return } # nothing to do
$TokenNames = @(Get-PSTokenProxy CommandArgument -Lines $TokenLines | Normalize-Identifier)
if (-not $TokenNames.Length) { return } # nothing to do
# No CommandInfo for these so we'll just store a bool
foreach ($TokenName in $TokenNames) {
$LocalFunctions[$TokenName] = $true
}
}
##############################################################################
#.SYNOPSIS
# Removes references to built-in Cmdlets, functions, variables, etc from
# the input stream.
##############################################################################
filter Exclude-BuiltInReferences {
if ($Force) { return $_ }
$Token = $_
$TokenName = Normalize-Identifier $Token.Content
# Is it built in?
# If so, skip it.
if ($Token.Type -eq 'Command') {
if ($BuiltInAliases[$TokenName]) { return }
if ($BuiltInCmdlets[$TokenName]) { return }
if ($BuiltInFunctions[$TokenName]) { return }
}
elseif ($Token.Type -eq 'Variable') {
if ($BuiltInVariables[$TokenName]) { return }
}
$Token
}
##############################################################################
#.SYNOPSIS
# Removes references to locally defined functions from the input stream.
##############################################################################
filter Exclude-LocalReferences {
if ($Force) { return $_ }
$Token = $_
$TokenName = Normalize-Identifier $Token.Content
# Is it built in?
# If so, skip it.
if ($Token.Type -eq 'Command') {
if ($LocalFunctions[$TokenName]) { return }
}
$Token
}
##############################################################################
#.SYNOPSIS
# Gets a single command with the specified name, taking into account that the
# command name may need to have conflicting wildcard characters escaped and
# avoiding the error when a command does not exist.
##############################################################################
function Get-SafeCommand([String]$Name) {
$SafeName = [System.Management.Automation.WildcardPattern]::Escape($Name)
$CommandInfo = Get-Command -Name $SafeName -ErrorAction SilentlyContinue | Select -First 1
if (-not $CommandInfo) { $CommandInfo = $ImportedAliases[$Name] }
if (-not $CommandInfo) { $CommandInfo = $ImportedFunctions[$Name] }
if (-not $CommandInfo) { $CommandInfo = $ImportedCmdlets[$Name] }
$CommandInfo
}
##############################################################################
#.SYNOPSIS
# Gets a single variable with the specified name, taking into account that the
# variable name may need to have conflicting wildcard characters escaped and
# avoiding the error when a variable does not exist.
##############################################################################
function Get-SafeVariable([String]$Name) {
$SafeName = [System.Management.Automation.WildcardPattern]::Escape($Name)
$VariableInfo = Get-Variable -Name $SafeName -Scope Global -ErrorAction SilentlyContinue | Select -First 1
$VariableInfo
}
##############################################################################
#.SYNOPSIS
# Selects the first instance of each distinct combination of token type and
# content (function/variable name etc) and includes the line number of the
# first occurrence.
#
#.DESCRIPTION
# This is a basically just a ugly way of doing:
# SELECT Type,Content,Min(StartLine) FROM ... GROUP BY Type,Content
# Which PowerShell cannot easily represent as far as I know
##############################################################################
function Select-UniqueTokens {
$Input |
Group-Object Type,Content |
Select-Object @(
@{N='Type'; E={ $_.Group[0].Type } },
@{N='Content'; E={ $_.Group[0].Content } },
@{N='Script'; E={ $_.Group[0].Script } }
@{N='StartLine'; E={ $_.Group[0].StartLine } }
)
}
##############################################################################
#.SYNOPSIS
# Creates a new PSObject for holding information about the external dependency
##############################################################################
function New-Dependency($Token) {
# Create a PSObject to hold the values
# we will output from this function
New-Object PSObject |
Add-Member -PassThru NoteProperty Script ($Token.Script) |
Add-Member -PassThru NoteProperty Module ($null) |
Add-Member -PassThru NoteProperty Type ($Token.Type) |
Add-Member -PassThru NoteProperty Name (Normalize-Identifier $Token.Content) |
Add-Member -PassThru NoteProperty Target ($null) |
Add-Member -PassThru NoteProperty Resolved ($false) |
Add-Member -PassThru NoteProperty File ($null)
}
} # begin
process {
if ($PSCmdlet.ParameterSetName -eq 'Selection') {
if (-not $PSISE) { throw 'The Selection parameter set is not valid outside of the PowerShell ISE.' }
if (-not $PSISE.CurrentOpenedFile) { throw 'There is no file currently opened.' }
if (-not $PSISE.CurrentOpenedFile.IsSaved) { throw 'Please save the currently active document first.' }
if ($PSISE.CurrentOpenedFile.IsUntitled) { throw 'Please save the currently active document first.' }
$ResolvedPaths = @(Resolve-Path -LiteralPath $PSISE.CurrentOpenedFile.FullPath)
}
elseif ($PSCmdlet.ParameterSetName -match '^(Literal)?Path$') {
switch ($PSCmdlet.ParameterSetName) {
Path { $ResolvedPaths = @(Resolve-Path -Path $Path) }
LiteralPath { $ResolvedPaths = @(Resolve-Path -LiteralPath $LiteralPath) }
}
}
# Discover imported modules and function definitions
Discover-BuiltInCommands
Discover-ModuleImports
Discover-LocalFunctions
# Parse the source code
# Gives back command and variable references
$Tokens = @(
Get-PSTokenProxy Command,Variable |
Exclude-BuiltInReferences |
Exclude-LocalReferences |
Select-UniqueTokens
)
foreach ($Token in $Tokens) {
$Dependency = New-Dependency $Token
# HANDLE VARIABLE REFERENCES
if ($Token.Type -eq 'Variable') {
if ($VariableInfo = Get-SafeVariable $Dependency.Name) {
$Dependency.Module = $VariableInfo.ModuleName
$Dependency.Resolved = $true
}
else {
# Skip this dependency
# Could not find this variable in global state
# So we can only assume that the variable is
# localized somewhere and cannot really be
# resolved as an external dependency
# TODO: maybe look for a setter or arg name
# to determine if it's really external or not
continue
}
} # Token Type = Variable
# HANDLE COMMAND REFERENCES
if ($Token.Type -eq 'Command') {
if ($CommandInfo = Get-SafeCommand $Dependency.Name) {
$Dependency.Type= $CommandInfo.CommandType
$Dependency.Resolved = $true
# Is it an alias?
# If so, we want to resolve the target
if ($CommandInfo.CommandType -eq 'Alias') {
# Is the alias resolved?
if ($CommandInfo.ResolvedCommandName) {
# Is alias the same name as the target?
# If so, this is generally a disambiguating alias and can be ignored.
if ($CommandInfo.Name -eq $CommandInfo.ResolvedCommandName) {
if ($BuiltInAliases[$CommandInfo.ResolvedCommandName]) { continue }
if ($BuiltInCmdlets[$CommandInfo.ResolvedCommandName]) { continue }
if ($BuiltInFunctions[$CommandInfo.ResolvedCommandName]) { continue }
}
$Dependency.Target = $CommandInfo.ResolvedCommandName
$CommandInfo = $CommandInfo.ResolvedCommand
}
else {
# The alias points to an unresolved command
$Dependency.Resolved = $false
}
} # Command Type = Alias
# Command Source Info
# Note that in the case of resolved aliases, CommandInfo is now
# pointing to the resolved command and modules/paths/etc will reflect that
if ($CommandInfo.ModuleName) { $Dependency.Module = $CommandInfo.ModuleName }
if ($CommandInfo.ScriptBlock.File) { $Dependency.File = $CommandInfo.ScriptBlock.File }
if ($CommandInfo.Path) { $Dependency.File = $CommandInfo.Path }
if ($CommandInfo.DLL) { $Dependency.File = $CommandInfo.DLL }
} # Is Command Is Resolved
} # Token Type = Command
if ($Unresolved -and $Dependency.Resolved) { continue }
[Void]$Dependencies.Add($Dependency)
} # foreach Token
} # process
end {
# returns all output at the end
$Dependencies | Sort Script,Module,Type,Name
} # end
}