WhoCalled.psm1


#region Classes
enum CallDirection
{
    Calls = 0
    CalledBy = 1
}

class CallInfo
{
    <#
        .SYNOPSIS
        A pseudo-child of System.Management.Automation.CommandInfo that's also a node in a graph of
        calls.
 
        We can't inherit because all the constructors of CommandInfo are marked internal.
    #>


    # Hot path - we'll implement directly
    [string]$Name
    [string]$Source
    [psmoduleinfo]$Module

    # This class is a tree node
    [Collections.Generic.ISet[CallInfo]]$CalledBy
    [Collections.Generic.IList[CallInfo]]$Calls
    hidden [int]$Depth

    # Inner object; we'll delegate calls to this
    hidden [Management.Automation.CommandInfo]$Command

    hidden [string]$Id

    #region Constructors
    CallInfo([string]$Name)
    {
        $this.Name = $Name
        $this.Initialise()
    }

    CallInfo([Management.Automation.CommandInfo]$Command)
    {
        $this.Command = $Command
        $this.Name = $Command.Name
        $this.Source = $Command.Source
        $this.Module = $Command.Module
        $this.Initialise()
    }

    hidden [void] Initialise()
    {
        $InheritedProperties = (
            'CmdletBinding',
            'CommandType',
            'DefaultParameterSet',
            'Definition',
            'Description',
            'HelpFile',
            # 'Module',
            'ModuleName',
            # 'Name',
            'Noun',
            'Options',
            'OutputType',
            'Parameters',
            'ParameterSets',
            'RemotingCapability',
            'ScriptBlock',
            # 'Source',
            'Verb',
            'Version',
            'Visibility',
            'HelpUri'
        )

        $InheritedProperties | ForEach-Object {
            Add-Member ScriptProperty -InputObject $this -Name $_ -Value ([scriptblock]::Create("`$this.Command.$_"))
        }

        $this.CalledBy = [Collections.Generic.HashSet[CallInfo]]::new()
        $this.Calls = [Collections.Generic.List[CallInfo]]::new()

        $this.Id = switch ($this.CommandType)
        {
            $null
            {
                '<not found>'
            }

            'Function'
            {
                $Qualifier = if ($this.Module)
                {
                    # https://github.com/PowerShell/PowerShell/blob/8cc39848bcd4fb98517adc79cdbe60234b375c59/src/System.Management.Automation/engine/Modules/PSModuleInfo.cs#L1596-L1599
                    $this.Module.Name, $this.Module.Guid, $this.Module.Version -join ':' -replace '::'
                }
                else
                {
                    $this.Command.Scriptblock.GetHashCode()
                }
                $Qualifier, $this.Name -join '\'
            }

            'Cmdlet'
            {
                $Qualifier = $this.Command.ImplementingType.FullName
                $Qualifier, $this.Name -join '\'
            }

            'Alias'
            {
                'Alias', $this.Name -join ':\'
            }

            'Application'
            {
                $this.Command.Path
            }

            default
            {
                throw [NotImplementedException]::new("No implementation for '$_'.")
            }
        }
    }
    #endregion Constructors


    hidden [System.Collections.Generic.IList[CallInfo]] AsList([int]$Depth, [CallDirection]$Direction)
    {
        [CallDirection]$OtherDirection = [int](-not $Direction)

        $Cloned = if ($this.Command) {[CallInfo]$this.Command} else {[CallInfo]$this.Name}
        $Cloned.Depth = $Depth

        $List = [System.Collections.Generic.List[CallInfo]]::new()
        $List.Add($Cloned)
        $Depth++
        foreach ($Call in ($this.$Direction | Sort-Object Name, Id))
        {
            $RecursedList = $Call.AsList($Depth, $Direction)

            [void]$Cloned.$Direction.Add($Call)
            [void]$RecursedList[0].$OtherDirection.Add($Cloned)

            $List.AddRange($RecursedList)
        }
        return $List
    }


    #region Overrides
    [string] ToString()
    {
        return $this.Name
    }

    [bool] Equals([object]$obj)
    {
        return $obj -is [CallInfo] -and $obj.Id -eq $this.Id
    }

    [int] GetHashCode()
    {
        return $this.Id.GetHashCode()
    }

    [Management.Automation.ParameterMetadata] ResolveParameter([string]$name)
    {
        if ($null -eq $this.Command)
        {
            throw [InvalidOperationException]::new("Cannot resolve parameter '$Name' for unresolved comand '$($this.Name)'.")
        }
        return $this.Command.ResolveParameter($name)
    }
    #endregion Overrides
}
#endregion Classes


#region Private
function Find-CallNameFromDefinition
{
    <#
        .DESCRIPTION
        Parse a function definition to find all commands called from the function.
    #>


    [Diagnostics.CodeAnalysis.SuppressMessage('PSReviewUnusedParameter', 'TokenFlags', Justification = "It's used in a scriptblock")]

    [OutputType([string[]])]
    [CmdletBinding(DefaultParameterSetName = 'FromFunction')]
    param
    (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Management.Automation.FunctionInfo]$Function,

        [Management.Automation.Language.TokenFlags]$TokenFlags = 'CommandName'
    )

    process
    {
        $Tokens = @()
        [void][Management.Automation.Language.Parser]::ParseInput($Function.Definition, [ref]$Tokens, [ref]$null)

        $CommandTokens = $Tokens | Where-Object {$_.TokenFlags -band $TokenFlags}
        $CommandTokens.Text | Sort-Object -Unique
    }
}
function Resolve-Command
{
    <#
        .DESCRIPTION
        Find commands. Aliases are optionally resolved to the command they alias.
 
        If a module is provided, and it is not null, command resolution is done in the module's
        scope. This allows resolution of private commands.
    #>


    [Diagnostics.CodeAnalysis.SuppressMessage('PSReviewUnusedParameter', 'ResolveAlias', Justification = "It's used in a scriptblock")]

    [OutputType([CallInfo[]])]
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory, ValueFromPipeline)]
        [SupportsWildcards()]
        [string]$Name,

        [AllowNull()]
        [psmoduleinfo]$Module,

        [switch]$ResolveAlias
    )

    begin
    {
        $Resolver = {
            param ([string]$Name, [string]$ModuleName, [switch]$ResolveAlias)

            try
            {
                return Get-Command $Name -ErrorAction Stop |
                    ForEach-Object {if ($ResolveAlias -and $_.CommandType -eq 'Alias') {$_.ResolvedCommand} else {$_}}
            }
            catch [Management.Automation.CommandNotFoundException]
            {
                Write-Warning "Command resolution failed for command '$Name'$(if ($ModuleName) {" in module '$ModuleName'"})."
            }
            catch
            {
                Write-Error -ErrorRecord $_
            }
            return $Name
        }
    }

    process
    {
        [CallInfo[]]$Calls = if ($Module)
        {
            # Running Get-Command for a non-imported module gives an uninitialised module object
            $Module = Import-Module $Module -PassThru -DisableNameChecking

            $Module.Invoke($Resolver, @($Name, $Module.Name, $ResolveAlias))
        }
        else
        {
            & $Resolver $Name '' $ResolveAlias
        }
        $Calls
    }
}
#endregion Private


#region Public
function Find-Call
{
    <#
        .SYNOPSIS
        For a given function, find what functions it calls.
 
        .DESCRIPTION
        For the purposes of working out dependencies, it may be good to know what a function depends
        on at the function scale.
 
        This command takes a function and builds a tree of functions called by that function.
 
        .INPUTS
 
        [string]
 
        [System.Management.Automation.CommandInfo]
 
        .OUTPUTS
 
        [CallInfo]
 
        This command outputs an object similar to System.Management.Automation.CommandInfo. Note
        that this is not a child class of CommandInfo.
 
        .EXAMPLE
        Find-Call Install-Module
 
        CommandType Name Version Source
        ----------- ---- ------- ------
        Function Install-Module 2.2.5 PowerShellGet
        Function Get-ProviderName 2.2.5 PowerShellGet
        Function Get-PSRepository 2.2.5 PowerShellGet
        Function New-ModuleSourceFromPackageSource 2.2.5 PowerShellGet
        Cmdlet Get-PackageSource 1.4.7 PackageManagement
        Function Install-NuGetClientBinaries 2.2.5 PowerShellGet
        Function Get-ParametersHashtable 2.2.5 PowerShellGet
        Cmdlet Get-PackageProvider 1.4.7 PackageManagement
        Cmdlet Import-PackageProvider 1.4.7 PackageManagement
        Cmdlet Install-PackageProvider 1.4.7 PackageManagement
        Function Test-RunningAsElevated 2.2.5 PowerShellGet
        Function ThrowError 2.2.5 PowerShellGet
        Function New-PSGetItemInfo 2.2.5 PowerShellGet
        Function Get-EntityName 2.2.5 PowerShellGet
        Function Get-First 2.2.5 PowerShellGet
        Function Get-SourceLocation 2.2.5 PowerShellGet
 
        For the 'Install-Module' command from the PowerShellGet module, determine the call tree.
 
        .EXAMPLE
        Find-Call Import-Plugz -Depth 2 -ResolveAlias -All
 
        WARNING: Resulting output is truncated as call tree has exceeded the set depth of 2.
        CommandType Name Version Source
        ----------- ---- ------- ------
        Function Import-Plugz 0.2.0 Plugz
        Cmdlet Export-ModuleMember 7.2.5.500 Microsoft.PowerShell.Core
        Function Get-PlugzConfig 0.2.0 Plugz
        Cmdlet Add-Member 7.0.0.0 Microsoft.PowerShell.Utility
        Function Import-Configuration 1.5.1 Configuration
        Cmdlet Join-Path 7.0.0.0 Microsoft.PowerShell.Management
        Cmdlet New-Module 7.2.5.500 Microsoft.PowerShell.Core
        Cmdlet Select-Object 7.0.0.0 Microsoft.PowerShell.Utility
        Cmdlet Set-Alias 7.0.0.0 Microsoft.PowerShell.Utility
        Cmdlet Set-Item 7.0.0.0 Microsoft.PowerShell.Management
        Cmdlet Set-Variable 7.0.0.0 Microsoft.PowerShell.Utility
        Function Test-CalledFromProfile 0.2.0 Plugz
        Cmdlet Get-PSCallStack 7.0.0.0 Microsoft.PowerShell.Utility
        Cmdlet Select-Object 7.0.0.0 Microsoft.PowerShell.Utility
        Cmdlet Where-Object 7.2.5.500 Microsoft.PowerShell.Core
        Cmdlet Test-Path 7.0.0.0 Microsoft.PowerShell.Management
        Cmdlet Where-Object 7.2.5.500 Microsoft.PowerShell.Core
        Cmdlet Write-Error 7.0.0.0 Microsoft.PowerShell.Utility
        Cmdlet Write-Verbose 7.0.0.0 Microsoft.PowerShell.Utility
 
        Find calls made by the 'Import-Plugz' command. Depth is limited to 2. Built-in commands are
        included. Aliases are resolved to the resolved commands.
    #>


    [Diagnostics.CodeAnalysis.SuppressMessage('PSReviewUnusedParameter', 'All', Justification = "It's used in a scriptblock")]

    [OutputType([CallInfo[]])]
    [CmdletBinding(DefaultParameterSetName = 'FromCommand', PositionalBinding = $false)]
    param
    (
        # Provide the name of a function to analyse. Wildcards are accepted.
        [Parameter(ParameterSetName = 'ByName', Mandatory, ValueFromPipeline, Position = 0)]
        [SupportsWildcards()]
        [string]$Name,

        # Provide a command object as input. This will be the output of Get-Command.
        [Parameter(ParameterSetName = 'FromCommand', Mandatory, ValueFromPipeline, Position = 0)]
        [Management.Automation.CommandInfo]$Command,

        # Maximum level of nesting to analyse. If this depth is exceeded, a warning will be emitted.
        [ValidateRange(0, 100)]
        [int]$Depth = 4,

        # Specifies to resolve aliases to the aliased command.
        [switch]$ResolveAlias,

        # Specifies to return all commands. By default, built-in modules are excluded.
        [switch]$All,

        # Only populate the cache
        [Parameter(DontShow)]
        [switch]$NoOutput,

        # For recursion
        [Parameter(DontShow, ParameterSetName = 'Recursing', Mandatory, ValueFromPipeline)]
        [CallInfo]$Caller,

        # For recursion
        [Parameter(DontShow, ParameterSetName = 'Recursing')]
        [int]$_CallDepth = 0,

        # For detecting loops when recursing
        [Parameter(DontShow, ParameterSetName = 'Recursing')]
        [Collections.Generic.ISet[string]]$_StackSeen = [Collections.Generic.HashSet[string]]::new()
    )

    begin
    {
        if (-not $Script:CACHE)
        {
            $Script:CACHE = [Collections.Generic.Dictionary[string, CallInfo]]::new()
        }
    }

    process
    {
        #region Unify parameter sets
        if ($PSCmdlet.ParameterSetName -eq 'ByName')
        {
            $Params = [hashtable]$PSBoundParameters
            $Params.Remove('Name')
            return Get-Command $Name -ErrorAction Stop | Find-Call @Params
        }

        if ($PSCmdlet.ParameterSetName -eq 'Recursing')
        {
            $Command = $Caller.Command
        }
        else
        {
            $Caller = [CallInfo]$Command
        }
        #endregion Unify parameter sets

        #region Early exit conditions
        $_StackSeen = [Collections.Generic.HashSet[string]]::new($_StackSeen)
        if (-not $_StackSeen.Add($Caller.Id))
        {
            Write-Debug "Already seen: $Caller"
            return
        }

        if ($_CallDepth -ge $Depth)
        {
            Write-Warning "Resulting output is truncated as call tree has exceeded the set depth of $Depth`: $Caller"
            # ...since we always return the original caller, return it when depth is 0...
            if ($Depth -eq 0) {return $Caller} else {return}
        }

        if (-not ($Command -as [Management.Automation.FunctionInfo]))
        {
            $Message = if ($Command) {"Not a function, cannot parse for calls: $Caller"} else {"Command not found: $Caller"}
            Write-Verbose $Message
            Write-Debug $Message
            return
        }
        #endregion Early exit conditions

        #region Cache hit or parse and cache
        # The call may have bottomed out on depth when it was first cached.
        # A cache hit saves parsing; it doesn't save recursion.
        $Found = $Script:CACHE[$Caller.Id]
        if ($Found)
        {
            Write-Debug "$Caller`: cache hit"
            $Caller.CalledBy | ForEach-Object {[void]$Found.CalledBy.Add($_)}
            $Caller = $Found
        }
        else
        {
            $CallNames = $Command |
                Where-Object Name |
                Find-CallNameFromDefinition

            $CallNames |
                Resolve-Command -Module $Command.Module -ResolveAlias:$ResolveAlias |
                Write-Output |
                Where-Object Id -NE $Caller.Id |     # Don't include recursive calls
                ForEach-Object {
                    [void]$_.CalledBy.Add($Caller)
                    $Caller.Calls.Add($_)
                }

            Write-Debug "$Caller`: caching"
            $Script:CACHE[$Caller.Id] = $Caller
        }
        #endregion Cache hit or parse and cache

        #region Recurse
        $Calls = $Caller.Calls
        if (-not $All)
        {
            $Calls = $Calls | Where-Object Source -notmatch '^Microsoft.PowerShell'
        }

        if ($Calls)
        {
            $RecurseParams = @{
                Depth           = $Depth
                ResolveAlias    = $ResolveAlias
                All             = $All
                _CallDepth      = $_CallDepth + 1
                _StackSeen      = $_StackSeen
                WarningAction   = 'SilentlyContinue'
                WarningVariable = 'Warnings'
            }
            $Calls | Find-Call @RecurseParams
        }
        #endregion Recurse

        #region Output
        if ($PSCmdlet.ParameterSetName -ne 'Recursing' -and -not $NoOutput)
        {
            if ($Warnings)
            {
                $Warnings | Sort-Object -Unique | Write-Warning
            }

            $Caller.AsList(0, 'Calls') | Where-Object {
                $_.Depth -le $Depth -and
                ($All -or $_.Source -notmatch '^Microsoft.PowerShell')
            }
        }
        #endregion Output
    }
}
function Find-Caller
{
    <#
        .SYNOPSIS
        For a given function, find functions that call it.
 
        .DESCRIPTION
        For the purposes of working out dependencies, it may be good to know what depends on a
        function at the function scale.
 
        This command takes a function and builds a tree of functions that call it.
 
        .INPUTS
 
        [string]
 
        [System.Management.Automation.CommandInfo]
 
        .OUTPUTS
 
        [CallInfo]
 
        This command outputs an object similar to System.Management.Automation.CommandInfo. Note
        that this is not a child class of CommandInfo.
 
        .EXAMPLE
        Find-Caller Get-ModuleDependencies -Module PowerShellGet
 
        CommandType Name Version Source
        ----------- ---- ------- ------
        Function Get-ModuleDependencies 2.2.5 PowerShellGet
        Function Publish-PSArtifactUtility 2.2.5 PowerShellGet
        Function Publish-Module 2.2.5 PowerShellGet
        Function Publish-Script 2.2.5 PowerShellGet
 
        Find all calls made to the 'Get-ModuleDependencies' command from commands in the
        PowerShellGet module.
 
        Note that the 'Get-ModuleDependencies' command is not exported; it is a private command in
        the PowerShellGet module. This command will import modules in order to resolve private
        commands.
 
        .EXAMPLE
        Find-Caller Import-* -Module Plugz, Metadata, Configuration, '' -Depth 2
 
        CommandType Name Version Source
        ----------- ---- ------- ------
        Function Import-Configuration 1.5.1 Configuration
        Function Get-PlugzConfig 0.2.0 Plugz
        Function Export-PlugzProfile 0.2.0 Plugz
        Function Import-Plugz 0.2.0 Plugz
        Function Import-Metadata 1.5.3 Metadata
        Function Import-Configuration 1.5.1 Configuration
        Function Get-PlugzConfig 0.2.0 Plugz
        Function Import-ParameterConfiguration 1.5.1 Configuration
        Function Import-Plugz 0.2.0 Plugz
        Function Import-ParameterConfiguration 1.5.1 Configuration
        Function Import-GitModule
        Function Import-CommonModule
 
        Find calls made to any commands matching 'Import-*' from commands in the Plugz, Metadata, or
        Configuration modules, or from commands in the current scope that are not exported from any
        module. Depth is limited to 2.
 
        The module parameter includes an empty string argument. This causes the search to include
        functions that are not defined in a module; in this case, the 'Import-GitModule' and
        'Import-CommonModule' functions, which are defined in the user's profile.
 
        Note that the modules will be imported.
    #>


    param
    (
        # The name of a command to find callers of.
        [Parameter(ParameterSetName = 'ByName', Mandatory, ValueFromPipeline, Position = 0)]
        [string]$Name,

        # The command object to find callers of.
        [Parameter(ParameterSetName = 'FromCommand', Mandatory, ValueFromPipeline, Position = 0)]
        [Management.Automation.CommandInfo]$Command,

        # Modules to search for callers. Include a null or an empty string to include functions that
        # are not defined in a module.
        [Parameter(Mandatory, Position = 1)]
        [AllowEmptyString()]
        [string[]]$Module,

        # Maximum level of nesting to analyse. If this depth is exceeded, a warning will be emitted.
        [ValidateRange(0, 100)]
        [int]$Depth = 4
    )

    begin
    {
        $Module = $Module | Where-Object Length
        $IncludeCurrentScope = $Module.Count -ne $PSBoundParameters.Module.Count

        $ToImport = @()
        [psmoduleinfo[]]$Modules = $Module | ForEach-Object {
            $_Module = Get-Module $_ -ErrorAction Ignore
            if ($_Module) {$_Module} else {$ToImport += $_}
        }

        if ($ToImport)
        {
            $i = 0
            $Activity = "Importing modules"
            Write-Progress -Activity $Activity -PercentComplete 0
            $ToImport | ForEach-Object {
                $Percent = 100 * $i++ / $ToImport.Count
                Write-Progress -Activity $Activity -Status $_ -PercentComplete $Percent
                $Modules += Import-Module $_ -PassThru -DisableNameChecking
            }
            Write-Progress -Activity $Activity -Completed
        }

        $Commands = $Modules | ForEach-Object {
            $_.Invoke({Get-Command -Module $args[0]}, $_)
        }

        if ($IncludeCurrentScope)
        {
            $Commands += Get-Command -CommandType Function | Where-Object Module -eq $null
        }

        $Params = @{
            NoOutput        = $true     # Only populate cache
            ResolveAlias    = $true
            Depth           = $Depth
            WarningAction   = 'SilentlyContinue'
            WarningVariable = 'Warnings'
        }

        $i = 0
        $Activity = "Finding function calls"
        $Commands | ForEach-Object {
            $Percent = 100 * $i++ / $Commands.Count
            Write-Progress -Activity $Activity -Status $_ -PercentComplete $Percent
            Find-Call $_ @Params
        } -End {
            Write-Progress -Activity $Activity -Completed
        }

        if ($Warnings)
        {
            $Warnings | Sort-Object -Unique | Write-Warning
        }
    }

    process
    {
        if ($PSCmdlet.ParameterSetName -eq 'FromCommand')
        {
            $Calls = [CallInfo]$Command
        }
        else
        {
            if ($Name -match '(?<Source>.*)\\(?<Name>.*?)')
            {
                $Name = $Matches.Name
                $Source = $Matches.Source
            }
            else {$Source = ''}

            $CallIds = $Script:CACHE.Keys -like "*$Name"
            if ($Source)
            {
                $CallIds = @($CallIds) -like "$Source`:*"
            }
            $Calls = $Script:CACHE[$CallIds]
        }

        if (-not $Calls)
        {
            Write-Error "Could not find command '$_'." -ErrorAction Stop
        }
        $Calls | ForEach-Object {
            $_.AsList(0, 'CalledBy') | Where-Object Depth -le $Depth
        }
    }
}
#endregion Public