PsImport.psm1

using namespace System.Management.Automation.Language
#region Classes
Class PsImport {
    static [System.Collections.Generic.List[string]] $ExcludedNames
    static [System.Collections.Generic.Dictionary[string, FunctionDetails]] $Functions # Dictionary of Functions that have already been parsed, so we won't have to do it over again (for performance reasons).
    static [FunctionDetails[]] GetFunctions([Query[]]$FnNames) { return [PsImport]::GetFunctions($FnNames, $false) }
    static [FunctionDetails[]] GetFunctions([Query[]]$FnNames, [bool]$throwOnFailure) {
        [ValidateNotNullOrEmpty()][Query[]]$FnNames = $FnNames;
        $res = @(); $_FnNames = @()
        foreach ($Fn in $FnNames) {
            $_FnNames += switch ($true) {
                $Fn.Text.Equals('*') { foreach ($Name in [PsImport]::GetFnNames()) { $res += [PsImport]::GetFunctions($Name) } ; break }
                $Fn.Text.Contains('*') {
                    $AllNames = [PsImport]::GetFnNames(); $Fn_Names = @($AllNames | Where-Object { $_ -like $Fn.Text });
                    if (($Fn_Names | Where-Object { $_ -notin $AllNames }).Count -gt 0 -and $throwOnFailure) {
                        throw [System.Management.Automation.ItemNotFoundException]::New($($Fn_Names -join ', '))
                    }; $Fn_Names; break
                }
                ([PsImport]::IsValidSource($FnNames, $false)) { $(Get-Command -CommandType Function | Where-Object { $_.Source -eq "$($Fn.Text)" } | Select-Object -ExpandProperty Name); break }
                Default { $Fn.Text }
            }
        }
        if ($res.Count -ne 0) { return $res }
        foreach ($Name in $_FnNames) {
            if ([bool]$(try { [PsImport]::Functions.Keys.Contains($Name) } catch { $false })) { $res += [PsImport]::Functions["$Name"]; continue }
            $c = Get-Command $Name -CommandType Function -ErrorAction Ignore
            if ($null -eq $c) { continue }
            [string]$fn = $("function script:$Name {`n" + $((((($c | Format-List) | Out-String) -Split ('Definition :')) -split ('CommandType : Function')) -split ("Name : $($Name)")).TrimEnd().Replace('# .EXTERNALHELP', '# EXTERNALHELP').Trim() + "`n}")
            $res += [FunctionDetails]::New($c.Module.Path, $Name, [scriptblock]::Create("$fn"))
        }
        if ($res.Count -eq 0) {
            $_Message = "Could not find function(s). Named: $($FnNames -join ', ')"
            if ($throwOnFailure) { throw [System.Management.Automation.ItemNotFoundException]::New($_Message) }
            $(Get-Variable -Name host).Value.UI.WriteWarningLine("$_Message")
        }
        return $res
    }
    static [FunctionDetails[]] GetFunctions([Query[]]$FnNames, [string[]]$FilePaths) {
        return [PsImport]::GetFunctions($FnNames, $FilePaths, $false)
    }
    static [FunctionDetails[]] GetFunctions([Query[]]$FnNames, [string[]]$FilePaths, [bool]$throwOnFailure) {
        [ValidateNotNullOrEmpty()][string[]]$FilePaths = $FilePaths; [ValidateNotNullOrEmpty()][Query[]]$FnNames = $FnNames
        $result = @(); $_Functions = @(); $_FilePaths = @(); [string[]]$PathsToSearch = @();
        foreach ($line in $FilePaths) {
            if ([string]::IsNullOrWhiteSpace("$line")) { continue }
            if ([Regex]::IsMatch("$line", '^https?:\/\/[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(:[0-9]+)?\/?.*$')) {
                $PathsToSearch += "$line"; continue
            }
            $PathsToSearch += Resolve-FilePath "$line" -Extensions '.ps1', '.psm1'
        }
        foreach ($path in $PathsToSearch) {
            if (![string]::IsNullOrWhiteSpace("$Path")) {
                $path = [PsImport]::ParseLink($path)
                if (!$path.Scheme.IsValid) {
                    throw [System.IO.InvalidDataException]::New("'$($path.FullName)' is not a valid filePath or HTTPS URL.")
                }
                if ([Regex]::IsMatch($path.FullName, '^https:\/\/[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(:[0-9]+)?\/?.*$')) {
                    $outFile = [IO.FileInfo]::New([IO.Path]::ChangeExtension([IO.Path]::Combine([IO.Path]::GetTempPath(), [IO.Path]::GetRandomFileName()), '.ps1'))
                    [void][PsImport]::DownloadFile($path.FullName, $outFile.FullName);
                    $_FilePaths += $outFile.FullName; Continue
                }; $_FilePaths += $path.FullName
            }
        }
        if ($_FilePaths.count -eq 0) {
            if ($throwOnFailure) { throw [System.IO.FileNotFoundException]::New("$FilePaths") }
            return $_Functions #still null
        }
        $_FilePaths = $_FilePaths | Sort-Object -Unique
        foreach ($file in $_FilePaths) {
            $_Functions += [PsImport]::ParseFile($file)
        }
        if (!$FnNames.Text.Contains('*')) {
            foreach ($q in $FnNames) {
                if ($q.Text.Contains('*')) {
                    $result += $_Functions.Where({ $_.Name -like $q.Text })
                    Continue
                }; $result += $_Functions.Where({ $_.Name -eq $q.Text })
            }
        } else {
            $result += $_Functions
        }
        $result = $result | Sort-Object -Property Name -Unique
        return $result
    }
    [System.Management.Automation.Language.FunctionDefinitionAST[]] static GetFncDefinition([string]$Path) {
        return [PsImport]::GetFncDefinition([System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$Null))
    }
    [System.Management.Automation.Language.FunctionDefinitionAST[]] static GetFncDefinition([scriptBlock]$scriptBlock) {
        return [PsImport]::GetFncDefinition([System.Management.Automation.Language.Parser]::ParseInput($scriptBlock.Tostring(), [ref]$null, [ref]$Null))
    }
    [System.Management.Automation.Language.FunctionDefinitionAST[]] static hidden GetFncDefinition([System.Management.Automation.Language.ScriptBlockAst]$ast) {
        $RawFunctions = $null
        $RawAstDocument = $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.Ast] }, $true)
        if ($RawASTDocument.Count -gt 0 ) {
            # https://stackoverflow.com/questions/45929043/get-all-functions-in-a-powershell-script/45929412
            $RawFunctions = $RawASTDocument.FindAll({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $($args[0].parent) -isnot [System.Management.Automation.Language.FunctionMemberAst] })
        }
        return $RawFunctions
    }
    static hidden [string[]] GetFnNames() {
        # Get all Names of loaded funtions whose source is known (loaded from modules)
        $sources = [PsImport]::GetCommandSources()
        return $(Get-Command -CommandType Function | Where-Object { $_.Source -in $sources }).Name -as [string[]]
    }
    static [FunctionDetails[]] ParseFile([string[]]$Path) {
        return [PsImport]::ParseFile($Path, $false, $false)
    }
    static [FunctionDetails[]] ParseFile([string[]]$Path, [bool]$ExcludePSCmdlets) {
        return [PsImport]::ParseFile($Path, $ExcludePSCmdlets, $false)
    }
    static [FunctionDetails[]] ParseFile([string[]]$Path, [bool]$ExcludePSCmdlets, [bool]$UseTitleCase) {
        if ([PsImport]::ExcludedNames.Count -eq 0 -and $ExcludePSCmdlets) {
            [PsImport]::ExcludedNames = [System.Collections.Generic.List[string]]::new()
            $((Get-Command -Module @(
                        "Microsoft.PowerShell.Archive", "Microsoft.PowerShell.Utility",
                        "Microsoft.PowerShell.ODataUtils", "Microsoft.PowerShell.Operation.Validation",
                        "Microsoft.PowerShell.Management", "Microsoft.PowerShell.Core", "Microsoft.PowerShell.LocalAccounts",
                        "Microsoft.WSMan.Management", "Microsoft.PowerShell.Security", "Microsoft.PowerShell.Diagnostics", "Microsoft.PowerShell.Host"
                    )
                ).Name + (Get-Alias).Name).Foreach({
                    [void][PsImport]::ExcludedNames.Add($_)
                }
            )
        }
        $FnDetails = @(); $Paths = (Resolve-FilePath -Paths $Path -throwOnFailure:$false).Where({
                $item = Get-Item -Path $_; $item -is [system.io.FileInfo] -and $item.Extension -in @('.ps1', '.psm1')
            }
        )
        forEach ($p in $Paths) {
            $FncDef = [PsImport]::GetFncDefinition($p)
            foreach ($RawASTFunction in $FncDef) {
                $FnDetails += if ([PsImport]::ExcludedNames.Count -gt 0) {
                    [FunctionDetails]::Create($p, $RawASTFunction, [PsImport]::ExcludedNames, $UseTitleCase)
                } else {
                    [FunctionDetails]::Create($p, $RawASTFunction, $UseTitleCase)
                }
            }
        }
        $FnDetails | ForEach-Object { [void][PsImport]::Record($_) }
        return $FnDetails
    }
    static [psobject] ParseLink([string]$text) {
        [ValidateNotNullOrEmpty()][string]$text = $text
        $uri = $text -as 'Uri'; if ($uri -isnot [Uri]) {
            throw [System.InvalidOperationException]::New("Could not create uri from text '$text'.")
        }; $Scheme = $uri.Scheme
        if ([regex]::IsMatch($text, '^(\/[a-zA-Z0-9_-]+)+|([a-zA-Z]:\\(((?![<>:"\/\\|?*]).)+\\?)*((?![<>:"\/\\|?*]).)+)$')) {
            if ($text.ToCharArray().Where({ $_ -in [IO.Path]::InvalidPathChars }).Count -eq 0) {
                $Scheme = 'file'
            } else {
                Write-Debug "'$text' has invalidPathChars in it !" -Debug
            }
        }
        $isValid = $Scheme -in @('file', 'https')
        $OutptObject = [pscustomobject]@{
            FullName = $text
            Scheme   = [PSCustomObject]@{
                Name    = $Scheme
                IsValid = $isValid
            }
        }
        return $OutptObject
    }
    static [void] DownloadFile([string]$uri, [string]$outFile) {
        [PsImport]::DownloadFile($uri, $outFile, $false)
    }
    static [void] DownloadFile([string]$uri, [string]$outFile, [bool]$Force) {
        [ValidateNotNullOrEmpty()][string]$uri = [uri]$uri;
        [ValidateNotNullOrEmpty()][string]$outFile = [IO.Path]::GetFullPath($outFile)
        if ((Test-Path -Path $outFile -PathType Leaf -ErrorAction Ignore)) {
            if (!$Force) { throw "$outFile already exists" }
            Remove-Item $outFile -Force -ErrorAction Ignore | Out-Null
        }
        $Name = Split-Path $uri -Leaf;
        [version]$dotNET_Framework_version = [string]::Join('.', [System.Environment]::Version.Major, [System.Environment]::Version.Minor)
        if ($dotNET_Framework_version -ge [version]'4.5') {
            # since System.Net.Http.HttpCompletionOption enumeration is not available in .NET Framework versions prior to 4.5
            # &yes this is faster than iwr, so u better off update your dotnet versions.
            Write-Verbose "Downloading $Name to $Outfile ... "
            $client = [System.Net.Http.HttpClient]::New()
            $client.DefaultRequestHeaders.Add("x-ms-download-header-content-disposition", "attachment")
            $client.DefaultRequestHeaders.Add("x-ms-download-content-type", "application/octet-stream")
            $client.DefaultRequestHeaders.Add("x-ms-download-length", "0")
            $client.DefaultRequestHeaders.Add("x-ms-download-id", [Guid]::NewGuid().ToString())
            # Download the file and save it to a Stream
            $response = $client.GetAsync($uri, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).Result
            $contents = $response.Content; if ($null -eq $contents) { Throw [System.InvalidOperationException]::New('Got $null HttpResponse.Result.content. Please Try again.') }
            $stream = $contents.ReadAsStreamAsync().Result
            # Create a FileStream object to write the data to the file
            $fileStream = [System.IO.FileStream]::new($outFile, [System.IO.FileMode]::Create)
            # Copy the data from the Stream to the FileStream
            $stream.CopyTo($fileStream)
            # Close the Stream and FileStream
            $stream.Close()
            $fileStream.Close()
            Write-Verbose "Download Complete."
        } else {
            Write-Debug "Using iwr :(" -Debug
            Invoke-WebRequest -Uri $uri -OutFile $outFile -Verbose:$false
        }
    }
    static hidden [string[]] GetCommandSources() {
        [string[]]$availableSources = @(Get-Command -CommandType Function | Select-Object Source -Unique).Source | Where-Object { $_.Length -gt 0 }
        return $availableSources
    }
    static hidden [bool] IsValidSource([String]$Source, [bool]$throwOnFailure) {
        $IsValid = $Source -in [PsImport]::GetCommandSources()
        if (!$IsValid -and $throwOnFailure) { throw $(New-Object System.Management.Automation.ErrorRecord $([System.Management.Automation.ItemNotFoundException]"Source named '$Source' was not found"), "ItemNotFoundException", $([System.Management.Automation.ErrorCategory]::ObjectNotFound), "PID: $((Get-Variable -Name PID).Value)") }
        return $IsValid
    }
    static [void] Record([FunctionDetails]$result) {
        $_nl = $null; $Should_Add = [bool]$(try { ![PsImport]::Functions.Keys.Contains($result.Name) } catch {
                $_nl = $_.Exception.Message.Equals('You cannot call a method on a null-valued expression.'); $_nl
            }
        ); if ($_nl) { [PsImport]::Functions = [System.Collections.Generic.Dictionary[string, FunctionDetails]]::New() }
        if ($Should_Add) {
            [PsImport]::Functions.Add($result.Name, $result)
        }
        # else { Write-Debug "[Recording] Skipped $($result.Name)" }
    }
    static [void] Record([FunctionDetails[]]$result) {
        foreach ($item in $result) { [PsImport]::Record($item) }
    }
    static [String] ToTitleCase ([string]$String) { return (Get-Culture).TextInfo.ToTitleCase($String.ToLower()) }
    static [hashtable] ReadPSDataFile([string]$FilePath) {
        return [scriptblock]::Create("$(Get-Content $FilePath | Out-String)").Invoke()
    }
}
class Query: Microsoft.PowerShell.Cmdletization.QueryBuilder {
    [ValidateNotNullOrEmpty()][string]$text
    Query() {}
    Query([string]$text) {
        $this.Text = $text
    }
}
class FunctionDetails {
    [string]$Name
    [string]$Path
    [string]$Source
    [System.Collections.ArrayList]$Commands = @()
    hidden [string]$DefaultParameterSet
    hidden [scriptblock]$ScriptBlock
    hidden [PsmoduleInfo]$Module
    hidden [string]$Description
    hidden [string]$ModuleName
    hidden [version]$Version
    hidden [string]$HelpUri
    hidden [string]$Noun
    hidden [string]$Verb
    hidden [ValidateNotNull()][System.Management.Automation.Language.FunctionDefinitionAST]$Definition
    FunctionDetails ([string]$Path, [string]$Name, [scriptblock]$ScriptBlock) {
        $FnDetails = @(); $FncDefinition = [PsImport]::GetFncDefinition($ScriptBlock)
        foreach ($FncAST in $FncDefinition) { $FnDetails += [FunctionDetails]::Create($path, $FncAST, $false) }
        $this.Definition = $FnDetails.Definition;
        $this.Path = Resolve-FilePath -Path $path -NoAmbiguous
        $this.Source = $this.Path.Split([IO.Path]::DirectorySeparatorChar)[-2]
        $this.SetName($Name) ; $this.SetCommands($false); $this.Module = Get-Module -Name $this.Source -ErrorAction Ignore
        $this.ScriptBlock = [scriptBlock]::Create("$($this.Definition.Extent.Text -replace '(?<=^function\s)(?!script:)', 'script:')")
    }
    FunctionDetails ([string]$Path, [System.Management.Automation.Language.FunctionDefinitionAST]$Raw, [Bool]$UseTitleCase) {
        $this.Definition = $Raw;
        $this.Path = Resolve-FilePath -Path $path -NoAmbiguous
        $this.Source = $this.Path.Split([IO.Path]::DirectorySeparatorChar)[-2]
        $this.SetCommands($UseTitleCase); $this.Module = Get-Module -Name $this.Source -ErrorAction Ignore
        $this.SetName($(if ($UseTitleCase) { [PsImport]::ToTitleCase($this.Definition.name) } else { $this.Definition.name }))
        $this.ScriptBlock = [scriptBlock]::Create("$($this.Definition.Extent.Text -replace '(?<=^function\s)(?!script:)', 'script:')")
    }
    FunctionDetails ([string]$Path, [System.Management.Automation.Language.FunctionDefinitionAST]$Raw, [string[]]$NamesToExculde, [Bool]$UseTitleCase) {
        $this.Definition = $Raw;
        $this.Path = Resolve-FilePath -Path $path -NoAmbiguous
        $this.Source = $this.Path.Split([IO.Path]::DirectorySeparatorChar)[-2]
        $this.SetCommands($NamesToExculde, $UseTitleCase); $this.Module = Get-Module -Name $this.Source -ErrorAction Ignore
        $this.SetName($(if ($UseTitleCase) { [PsImport]::ToTitleCase($this.Definition.name) } else { $this.Definition.name }))
        $this.ScriptBlock = [scriptBlock]::Create("$($this.Definition.Extent.Text -replace '(?<=^function\s)(?!script:)', 'script:')")
    }
    [FunctionDetails] Static Create([string]$path, [System.Management.Automation.Language.FunctionDefinitionAST]$RawAST, [bool]$UseTitleCase) {
        $res = [FunctionDetails]::New($path, $RawAST, $UseTitleCase)
        [void][PsImport]::Record($res); return $res
    }
    [FunctionDetails] Static Create([string]$path, [System.Management.Automation.Language.FunctionDefinitionAST]$RawAST, [string[]]$NamesToExculde, [bool]$UseTitleCase) {
        $res = [FunctionDetails]::New($path, $RawAST, $NamesToExculde, $UseTitleCase)
        [void][PsImport]::Record($res); return $res
    }
    hidden [void] SetName([string]$text) {
        [ValidateNotNullOrEmpty()]$text = $text
        $text = switch ($true) {
            $text.StartsWith('script:') { $text.Substring(7); break }
            $text.StartsWith('local:') { $text.Substring(6); break }
            Default { $text }
        }
        $this.Name = $text
    }
    hidden [void] SetCommands ([bool]$UseTitleCase) {
        $this.SetCommands(@(), $UseTitleCase)
    }
    hidden [void] SetCommands ([string[]]$ExclusionList, [Bool]$UseTitleCase) {
        $t = $this.Definition.findall({ $args[0] -is [System.Management.Automation.Language.CommandAst] }, $true)
        if ($t.Count -le 0 ) { return }
        ($t.GetCommandName() | Select-Object -Unique).Foreach({
                $Command = if ($UseTitleCase ) { [PsImport]::ToTitleCase($_) } else { $_ };
                if ($ExclusionList -contains $Command) { continue };
                $this.Commands.Add($Command)
            }
        )
    }
}
#endregion Classes
$Private = Get-ChildItem ([IO.Path]::Combine($PSScriptRoot, 'Private')) -Filter "*.ps1" -ErrorAction SilentlyContinue
$Public = Get-ChildItem ([IO.Path]::Combine($PSScriptRoot, 'Public')) -Filter "*.ps1" -ErrorAction SilentlyContinue
# Load dependencies
$PrivateModules = [string[]](Get-ChildItem ([IO.Path]::Combine($PSScriptRoot, 'Private')) -ErrorAction SilentlyContinue | Where-Object { $_.PSIsContainer } | Select-Object -ExpandProperty FullName)
if ($PrivateModules.Count -gt 0) {
    foreach ($Module in $PrivateModules) {
        Try {
            Import-Module $Module -ErrorAction Stop
        } Catch {
            Write-Error "Failed to import module $Module : $_"
        }
    }
}
# Dot source the files
foreach ($file in ($Public, $Private)) {
    Try {
        . $file.fullname
    } Catch {
        Write-Warning "Failed to import function $($Import.BaseName): $_"
        $host.UI.WriteErrorLine($_)
    }
}
# Export Public Functions
$Public | ForEach-Object { Export-ModuleMember -Function $_.BaseName }
Export-ModuleMember -Alias @('Import', 'require')