PsImport.psm1

using namespace System.Management.Automation
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 = @(); $AllNames = [PsImport]::GetFnNames();
    foreach ($Fn in $FnNames) {
      $_FnNames += switch ($true) {
        $Fn.Text.Equals('*') { foreach ($Name in $AllNames) { $res += [PsImport]::GetFunctions($Name) } ; break }
        $Fn.Text.Contains('*') {
          $Fn_Names = @($AllNames | Where-Object { $_ -like $Fn.Text }); $NotFound = ($Fn_Names | Where-Object { $_ -notin $AllNames }).Count -gt 0
          if ($NotFound -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 it was already parsed, then just use it.
      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 = @(); $FilePaths = $FilePaths.Where({ ![string]::IsNullOrWhiteSpace($_) })
    $items = $FilePaths.ForEach({ if ([PsImport]::IsValidUri($_)) { "$_" } else { Resolve-FilePath "$_" -Extensions '.ps1', '.psm1' } }).Where({ ![string]::IsNullOrWhiteSpace("$_") })
    $items = ($items | Select-Object @{l = 'Parsed'; e = { [PsImport]::ParseLink($_) } }).Parsed
    $invalid = $items.Where({ !$_.Scheme.IsValid })
    $gisturl = $items.Where({ $_.Scheme.IsGistUrl })
    if ($invalid.Count -gt 0 -and $throwOnFailure) { throw [IO.InvalidDataException]::New("'$($invalid)' is not a valid filePath or HTTPS URL.") }
    if ($gisturl.Count -gt 0 -and $throwOnFailure) { throw "Get-GistContent is not implemented yet" }
    $_FilePaths = ($items.Where({ $_.Scheme.IsValid -and !$_.Scheme.IsGistUrl }) | Select-Object @{l = 'Path'; e = { if ([Regex]::IsMatch($_.FullName, '^https:\/\/[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(:[0-9]+)?\/?.*$')) { [PsImport]::DownloadFile($_.FullName, $([IO.FileInfo]::New([IO.Path]::ChangeExtension([IO.Path]::Combine([IO.Path]::GetTempPath(), [IO.Path]::GetRandomFileName()), '.ps1'))).FullName).FullName } else { $_.FullName } } }).Path
    if ($_FilePaths.count -eq 0 -and $throwOnFailure) { throw [IO.FileNotFoundException]::New("$FilePaths") }

    $_FilePaths = $_FilePaths | Sort-Object -Unique
    $_Functions = ($_FilePaths | Select-Object @{l = 'Parsed'; e = { [PsImport]::ParseFile($_) } }).Parsed
    if (!$FnNames.Text.Contains('*')) {
      foreach ($q in $FnNames) {
        $result += $(if ($q.Text.Contains('*')) {
            $_Functions.Where({ $_.Name -like $q.Text })
          } else {
            $_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)
    if (![PsImport].FnNames) {
      $s = [PsImport]::GetCommandSources()
      $n = $(Get-Command -CommandType Function | Where-Object { $_.Source -in $s }).Name -as [string[]]
      [PsImport].PsObject.Properties.Add([PsNoteProperty]::new('FnNames', $n))
    }
    return [PsImport].FnNames
  }
  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')
    $IsGistUrl = [Regex]::IsMatch($text, "^https://gist.github.com/[a-z0-9]+(?:/[a-z0-9]+)?$")
    $OutptObject = [pscustomobject]@{
      FullName = $text
      Scheme   = [PSCustomObject]@{
        Name      = $Scheme
        IsValid   = $IsValid
        IsGistUrl = $IsGistUrl
      }
    }
    return $OutptObject
  }
  static [IO.FileInfo] DownloadFile([uri]$url) {
    # No $outFile so we create ones ourselves, and use suffix to prevent duplicaltes
    $randomSuffix = [Guid]::NewGuid().Guid.subString(15).replace('-', [string]::Join('', (0..9 | Get-Random -Count 1)))
    return [PsImport]::DownloadFile($url, "$(Split-Path $url.AbsolutePath -Leaf)_$randomSuffix");
  }
  static [IO.FileInfo] DownloadFile([uri]$url, [string]$outFile) {
    return [PsImport]::DownloadFile($url, $outFile, $false)
  }
  static [IO.FileInfo] DownloadFile([uri]$url, [string]$outFile, [bool]$Force) {
    [ValidateNotNullOrEmpty()][uri]$url = [uri]$url;
    $outFile = [PsImport]::GetUnResolvedPath($outFile);
    if ([System.IO.Directory]::Exists($outFile)) {
      throw [InvalidOperationException]::new("outFile", "Please provide valid file path, not a directory.")
    }
    if ((Test-Path -Path $outFile -PathType Leaf -ErrorAction Ignore)) {
      if (!$Force) { throw "$outFile already exists" }
      Remove-Item $outFile -Force -ErrorAction Ignore | Out-Null
    }
    $stream = $null; $fileStream = $null; $name = Split-Path $url -Leaf;
    $request = [System.Net.HttpWebRequest]::Create($url)
    $request.UserAgent = "Mozilla/5.0"
    $response = $request.GetResponse()
    $contentLength = $response.ContentLength
    $stream = $response.GetResponseStream()
    $buffer = New-Object byte[] 1024
    $fileStream = [System.IO.FileStream]::new($outFile, [System.IO.FileMode]::CreateNew)
    $totalBytesReceived = 0
    $totalBytesToReceive = $contentLength
    while ($totalBytesToReceive -gt 0) {
      $bytesRead = $stream.Read($buffer, 0, 1024)
      $totalBytesReceived += $bytesRead
      $totalBytesToReceive -= $bytesRead
      $fileStream.Write($buffer, 0, $bytesRead)
      $percentComplete = [int]($totalBytesReceived / $contentLength * 100)
      Write-Progress -Activity "Downloading $name to $Outfile" -Status "Progress: $percentComplete%" -PercentComplete $percentComplete
    }
    try { Invoke-Command -ScriptBlock { $stream.Close(); $fileStream.Close() } -ErrorAction SilentlyContinue } catch { $null }
    return (Get-Item $outFile)
  }
  static hidden [string[]] GetCommandSources() {
    [string[]]$availableSources = @(Get-Command -CommandType Function | Select-Object Source -Unique).Source | Where-Object { $_.Length -gt 0 }
    return $availableSources
  }
  static [string] GetResolvedPath([string]$Path) {
    return [PsImport]::GetResolvedPath($((Get-Variable ExecutionContext).Value.SessionState), $Path)
  }
  static [string] GetResolvedPath([System.Management.Automation.SessionState]$session, [string]$Path) {
    $paths = $session.Path.GetResolvedPSPathFromPSPath($Path);
    if ($paths.Count -gt 1) {
      throw [System.IO.IOException]::new([string]::Format([cultureinfo]::InvariantCulture, "Path {0} is ambiguous", $Path))
    } elseif ($paths.Count -lt 1) {
      throw [System.IO.IOException]::new([string]::Format([cultureinfo]::InvariantCulture, "Path {0} not Found", $Path))
    }
    return $paths[0].Path
  }
  static [string] GetUnResolvedPath([string]$Path) {
    return [PsImport]::GetUnResolvedPath($((Get-Variable ExecutionContext).Value.SessionState), $Path)
  }
  static [string] GetUnResolvedPath([System.Management.Automation.SessionState]$session, [string]$Path) {
    return $session.Path.GetUnresolvedProviderPathFromPSPath($Path)
  }
  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 [bool] IsValidUri([String]$Uri) {
    return [Regex]::IsMatch("$Uri", '^https?:\/\/[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(:[0-9]+)?\/?.*$')
  }
  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

# Types that will be available to users when they import the module.
$typestoExport = @(
  [FunctionDetails],
  [PsImport]
)
$TypeAcceleratorsClass = [PsObject].Assembly.GetType('System.Management.Automation.TypeAccelerators')
foreach ($Type in $typestoExport) {
  if ($Type.FullName -in $TypeAcceleratorsClass::Get.Keys) {
    $Message = @(
      "Unable to register type accelerator '$($Type.FullName)'"
      'Accelerator already exists.'
    ) -join ' - '

    [System.Management.Automation.ErrorRecord]::new(
      [System.InvalidOperationException]::new($Message),
      'TypeAcceleratorAlreadyExists',
      [System.Management.Automation.ErrorCategory]::InvalidOperation,
      $Type.FullName
    ) | Write-Warning
  }
}
# Add type accelerators for every exportable type.
foreach ($Type in $typestoExport) {
  $TypeAcceleratorsClass::Add($Type.FullName, $Type)
}
# Remove type accelerators when the module is removed.
$MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = {
  foreach ($Type in $typestoExport) {
    $TypeAcceleratorsClass::Remove($Type.FullName)
  }
}.GetNewClosure();

$scripts = @();
$Public = Get-ChildItem "$PSScriptRoot/Public" -Filter "*.ps1" -Recurse -ErrorAction SilentlyContinue
$scripts += Get-ChildItem "$PSScriptRoot/Private" -Filter "*.ps1" -Recurse -ErrorAction SilentlyContinue
$scripts += $Public

foreach ($file in $scripts) {
  Try {
    if ([string]::IsNullOrWhiteSpace($file.fullname)) { continue }
    . "$($file.fullname)"
  } Catch {
    Write-Warning "Failed to import function $($file.BaseName): $_"
    $host.UI.WriteErrorLine($_)
  }
}

$Param = @{
  Function = $Public.BaseName
  Cmdlet   = '*'
  Alias    = '*'
}
Export-ModuleMember @Param