Documentarian.DevX.Private.psm1

# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

using namespace System.Management.Automation
using namespace System.Management.Automation.Language
#region Enums.Private

enum ClassLogLevels {
    <#
        .SYNOPSIS
        Defines the logging levels for classes.

        .DESCRIPTION
        Defines the logging levels for classes. The logging level determines the amount of log
        messages that a class should write when it performs operations.

        .LABEL None
        The class shouldn't write any log messages.

        .LABEL Basic
        The class should write basic log messages. Equivalent to verbose logging.

        .LABEL Detailed
        The class should write detailed log messages. Equivalent to debug logging.
    #>


    None     = 0
    Basic    = 1
    Detailed = 2
}

enum UncommonLineEndingAction {
  <#
    .SYNOPSIS
    Defines the action to take when an uncommon line ending is encountered.

    .DESCRIPTION
    Defines the action to take when an uncommon line ending is encountered. An uncommon line ending
    is a line ending that is not the standard line ending for the current platform.

    .LABEL SilentlyContinue
    Ignore the uncommon line ending and continue processing.

    .LABEL WarnAndContinue
    Emit a warning about the uncommon line ending and continue processing.

    .LABEL ErrorAndStop
    Emit an error about the uncommon line ending and stop processing.
  #>


  SilentlyContinue = 0
  WarnAndContinue  = 1
  ErrorAndStop     = 2
}

#endregion Enums.Private

#region Classes.Private

class DevXAstTypeTransformAttribute : ArgumentTransformationAttribute {
  <#
    .SYNOPSIS
    Transforms input into a valid AST type.

    .DESCRIPTION
    This attribute transforms input into a valid AST type. The input can be a type object or a
    string representing the type name. The type must be in the
    `System.Management.Automation.Language` namespace and must end in `Ast`. If the input is not a
    valid AST type, an exception is thrown.
  #>


  #region Static properties
  #endregion Static properties

  #region Static methods
  #endregion Static methods

  #region Instance properties

  [object] Transform([EngineIntrinsics]$engineIntrinsics, [System.Object]$inputData) {
    $outputData = switch ($inputData) {
      { $_ -is [System.Type] } {
        if (Test-DevXIsAstType -Type $_) {
          $_
        } else {
          $errorMessage = @(
            "Specified type '$($_.GetType().FullName)' is not an AST type;"
            "valid types must be in the 'System.Management.Automation.Language' namespace"
            "and must end in 'Ast'"
            'You can use Get-AstType to discover valid AST Types'
            'and to pass for this argument.'
          ) -join ' '
          throw [ArgumentTransformationMetadataException]::New(
            $errorMessage
          )
        }
      }
      { $_ -is [string] } {
        try {
          Get-AstType -Name $_ -ErrorAction Stop
        } catch {
          $errorMessage = @(
            "Specified type name '$_' is not an AST type;"
            "valid types must be in the 'System.Management.Automation.Language' namespace"
            "and must end in 'Ast'"
            'You can use Get-AstType to discover valid AST Types'
            'and to pass for this argument.'
          ) -join ' '
          throw [ArgumentTransformationMetadataException]::New(
            $errorMessage
          )
        }
      }
      default {
        $errorMessage = @(
          "Could not convert input ($_)"
          "of type '$($_.GetType().FullName)'"
          'to a valid AST Type.'
          'Specify the type itself or its name as a string.'
          'You can use Get-AstType to discover valid AST Types'
          'and to pass for this argument.'
        ) -join ' '
        throw [ArgumentTransformationMetadataException]::New(
          $errorMessage
        )
      }
    }

    return $outputData
  }

  #endregion Instance properties

  #region Instance methods
  #endregion Instance methods

  #region Constructors
  #endregion Constructors
}

class DevXValidatePowerShellScriptPathAttribute : ValidateArgumentsAttribute {
  <#
    .SYNOPSIS
    Validates that the specified path is for a valid PowerShell script file.

    .DESCRIPTION
    This attribute validates that the specified path is for a valid PowerShell script file. The
    path must be a file path to a PowerShell file with one of the following extensions: `.ps1`,
    `.psm1`, or `.psd1`. The file must exist and be accessible.
  #>


  #region Static properties
  #endregion Static properties

  #region Static methods
  #endregion Static methods

  #region Instance properties
  #endregion Instance properties

  #region Instance methods

  [void]  Validate([object]$arguments, [EngineIntrinsics]$engineIntrinsics) {
    $path = $arguments

    if ([string]::IsNullOrWhiteSpace($path)) {
      throw [System.ArgumentNullException]::new()
    }

    try {
      $item = Get-Item -Path $path -ErrorAction Stop
    } catch [ItemNotFoundException] {
      throw [System.IO.FileNotFoundException]::new()
    }

    $messagePrefix = 'Path must be the file path to a PowerShell file'

    $provider = $Item.PSProvider.Name
    if ($provider -ne 'FileSystem') {
      throw [System.ArgumentException]::new(
        "$messagePrefix; specified provider '$provider' is invalid."
      )
    }

    $extension           = $Item.Extension
    $validExtensions     = @('.psd1', '.ps1', '.psm1')
    $isNotPowerShellFile = $Extension -notin $ValidExtensions

    if ($isNotPowerShellFile) {
      $errorMessage = @(
        "Specified file '$path' has extension '$extension',"
        'but it must be one of the following:'
          ($validExtensions -join ', ')
      ) -join ' '
      throw [System.ArgumentException]::new("$messagePrefix; $errorMessage")
    }
  }

  #endregion Instance methods

  #region Constructors
  #endregion Constructors
}

class DevXAstInfo {
  <#
    .SYNOPSIS
    Class synopsis
  #>


  #region Static properties
  #endregion Static properties

  #region Static methods
  #endregion Static methods

  #region Instance properties

  <#
    .Synopsis
    The abstract syntax tree (AST) of the script block.

    .Description
    This property contains the abstract syntax tree (AST) of the script block. The AST represents
    the structure of the script block, including the statements, expressions, and other elements
    that make up the script block. The AST is generated by the PowerShell parser when the script
    block is parsed.

  #>

  [ScriptBlockAst]
  $Ast

  <#
    .Synopsis
    The tokens found when parsing the script block.

    .Description
    This property contains the tokens found when parsing the script block. The tokens include the
    comments from the script block, which aren't available in the AST.
  #>

  [Token[]]
  $Tokens

  <#
    .Synopsis
    The parse errors found when parsing the script block.

    .Description
    This property contains the parse errors found when parsing the script block. This property is
    only populated if there are errors in the script block that prevent it from being parsed. The
    presence of these errors indicates that the script block is not valid PowerShell code and
    needs attention.
  #>

  [ParseError[]]
  $Errors

  #endregion Instance properties

  #region Instance methods
  #endregion Instance methods

  #region Constructors

  DevXAstInfo([ScriptBlock]$scriptBlock) {
    <#
      .SYNOPSIS
      Initializes a new instance of DevXAstInfo from a script block.

      .DESCRIPTION
      This constructor creates a new instance of DevXAstInfo from a script block. It parses the
      script block into an AST and stores the AST, tokens, and errors in the new instance.

      .PARAMETER scriptBlock
    #>

    $t, $e = $null
    $this.Ast = [Parser]::ParseInput(
      $scriptBlock.ToString(),
      [ref]$t,
      [ref]$e
    )
    $this.Tokens = $t
    $this.Errors = $e
  }

  DevXAstInfo([string]$path) {
    <#
      .SYNOPSIS
      Initializes a new instance of DevXAstInfo from a file path.

      .DESCRIPTION
      This constructor creates a new instance of DevXAstInfo from a file path. It parses the file
      into an AST and stores the AST, tokens, and errors in the new instance.

      .PARAMETER path
      The path to a PowerShell code file to parse into an AST.
    #>


    $t, $e = $null
    $this.Ast = [Parser]::ParseFile(
      $path,
      [ref]$t,
      [ref]$e
    )
    $this.Tokens = $t
    $this.Errors = $e
  }

  DevXAstInfo([ScriptBlockAst]$ast) {
    <#
      .SYNOPSIS
      Initializes a new instance of DevXAstInfo from a script block AST.

      .DESCRIPTION
      This constructor creates a new instance of DevXAstInfo from a script block AST. It stores the
      AST in the new instance. This contructor can't define the tokens or errors as it doesn't
      parse the code itself.

      .PARAMETER ast
      The script block AST to store in the new instance.
    #>


    $this.Ast = $ast
  }

  DevXAstInfo([ScriptBlockAst]$ast, [Token[]]$tokens, [ParseError[]]$errors) {
    <#
      .SYNOPSIS
      Initializes a new instance of DevXAstInfo from an AST, tokens, and errors.

      .DESCRIPTION
      This constructor creates a new instance of DevXAstInfo from an AST, tokens, and errors. It
      stores the AST, tokens, and errors in the new instance.

      .PARAMETER ast
      The script block AST to store in the new instance.

      .PARAMETER tokens
      The tokens to store in the new instance.

      .PARAMETER errors
      The parse errors to store in the new instance.
    #>


    $this.Ast    = $ast
    $this.Tokens = $tokens
    $this.Errors = $errors
  }

  #endregion Constructors
}

#endregion Classes.Private

#region Functions.Private

Function Find-Ast {
  <#
    .SYNOPSIS
    Search an AST for nodes that match a predicate.

    .DESCRIPTION
    This function searches an AST for nodes that match a predicate. The predicate can be a script
    block or a type. If a type is specified, the predicate is created from the type, returning any
    matching AST objects. You can specify the AST to search by providing an `AstInfo` object, a
    file path, or a script block.

    The function can search a single AST node or recurse through all child nodes. The function
    returns all matching nodes.

    .PARAMETER DevXAstInfo
    The `AstInfo` object to search. This parameter is mandatory if the **Path** or **ScriptBlock**
    parameters are not specified.

    .PARAMETER Path
    The file path to the script file containing the AST to search. This parameter is mandatory if
    the **DevXAstInfo** or **ScriptBlock** parameters are not specified.

    .PARAMETER ScriptBlock
    The script block containing the AST to search. This parameter is mandatory if the
    **DevXAstInfo** or **Path** parameters are not specified.

    .PARAMETER Predicate
    The predicates to use when searching the AST. This parameter is mandatory if the **Type**
    parameter is not specified. Each predicate must be a script block that returns `$true` if the
    AST node should be returned and otherwise `$false`.

    .PARAMETER Type
    The AST types to search for. This parameter is mandatory if the **Predicate** parameter is not
    specified. Each type must be a type object or a string representing the type name. The type
    must be in the `System.Management.Automation.Language` namespace and must end in `Ast`.

    .PARAMETER Recurse
    Indicates that the function should search all child nodes of the AST. By default, the function
    only searches the top-level nodes.

    .OUTPUTS System.Management.Automation.Language.Ast
    The function returns every AST node that matches the predicates or is in the type list.
  #>


  [CmdletBinding(DefaultParameterSetName = 'FromtAstInfo')]
  [OutputType([System.Management.Automation.Language.Ast])]
  Param(
    [Parameter(Mandatory, ParameterSetName = 'FromAstInfoWithPredicate')]
    [Parameter(Mandatory, ParameterSetName = 'FromAstInfoWithType')]
    [DevXAstInfo]
    $DevXAstInfo,

    [Parameter(Mandatory, ParameterSetName = 'FromPathWithPredicate')]
    [Parameter(Mandatory, ParameterSetName = 'FromPathWithType')]
    [string]
    $Path,

    [Parameter(Mandatory, ParameterSetName = 'FromScriptBlockWithPredicate')]
    [Parameter(Mandatory, ParameterSetName = 'FromScriptBlockWithType')]
    [scriptblock]
    $ScriptBlock,

    [Parameter(Mandatory, ParameterSetName = 'FromAstInfoWithPredicate')]
    [Parameter(Mandatory, ParameterSetName = 'FromPathWithPredicate')]
    [Parameter(Mandatory, ParameterSetName = 'FromScriptBlockWithPredicate')]
    [ScriptBlock[]]
    $Predicate,

    [Parameter(Mandatory, ParameterSetName = 'FromAstInfoWithType')]
    [Parameter(Mandatory, ParameterSetName = 'FromPathWithType')]
    [Parameter(Mandatory, ParameterSetName = 'FromScriptBlockWithType')]
    [DevXAstTypeTransformAttribute()]
    [System.Type[]]
    $Type,

    [Parameter()]
    [switch]
    $Recurse
  )

  begin {

  }

  process {
    if (-not [string]::IsNullOrEmpty($Path)) {
      $DevXAstInfo = Get-Ast -Path $Path
    } elseif ($null -ne $ScriptBlock) {
      $DevXAstInfo = Get-Ast -ScriptBlock $ScriptBlock
    }

    if ($null -ne $Type) {
      $Predicate = New-AstPredicate -Type $Type
    }

    $Predicate | ForEach-Object -Process {
      $DevXAstInfo.Ast.FindAll($_, $Recurse)
    }
  }

  end {

  }
}

Function Get-Ast {
  <#
    .SYNOPSIS
    Gets the abstract syntax tree (AST) of a PowerShell script.

    .DESCRIPTION
    This function gets the abstract syntax tree (AST) of a PowerShell script. The AST represents
    the structure of the script, including the statements, expressions, and other elements that
    make up the script. The AST is generated by the PowerShell parser when the script is parsed.

    You can specify the script to parse by providing a file path, a script block, or a string
    containing the script text.

    .PARAMETER Path
    The file path to the script file to parse. The file must be a valid PowerShell code file that
    exists and is accessible. This parameter is mandatory if the **ScriptBlock** or **Text**
    parameters aren't specified.

    .PARAMETER ScriptBlock
    The script block to parse. This parameter is mandatory if the **Path** or **Text** parameters
    aren't specified.

    .PARAMETER Text
    The text of the script to parse. This parameter is mandatory if the **Path** or **ScriptBlock**
    parameters aren't specified.

    .OUTPUTs
    Returns an instance of the `DevXAstInfo` class that contains the AST, tokens, and errors found
    when parsing the script.
  #>


  [CmdletBinding()]
  [OutputType([DevXAstInfo])]
  param(
    [Parameter(Mandatory, ParameterSetName = 'ByPath')]
    [DevXValidatePowerShellScriptPathAttribute()]
    [string]
    $Path,

    [Parameter(Mandatory, ParameterSetName = 'ByScriptBlock')]
    [scriptblock]
    $ScriptBlock,

    [Parameter(Mandatory, ParameterSetName = 'ByInputText')]
    [ValidateNotNullOrEmpty()]
    [string]
    $Text
  )

  begin {

  }

  process {
    switch ($PSCmdlet.ParameterSetName) {
      'ByPath' {
        $Path = Resolve-Path -Path $Path -ErrorAction Stop
        [DevXAstInfo]::New($Path)
      }
      'ByScriptBlock' {
        [DevXAstInfo]::New($ScriptBlock)
      }
      'ByInputText' {
        [DevXAstInfo]::New([ScriptBlock]::Create($Text))
      }
    }
  }

  end {

  }
}

Function Get-AstType {
  <#
    .SYNOPSIS
    Function synopsis.
  #>


  [CmdletBinding(DefaultParameterSetName = 'ByPattern')]
  [OutputType([Type])]
  Param(
    [Parameter(
      Mandatory,
      ParameterSetName = 'ByPattern',
      HelpMessage = 'Specify a valid regex pattern to match in the list of AST types'
    )]
    [string]
    $Pattern,

    [Parameter(
      Mandatory,
      ParameterSetName = 'ByName',
      HelpMessage = 'Specify a name to look for in the list of AST types; the "Ast" suffix is optional'
    )]
    [string[]]
    $Name
  )

  begin {
    # ignore exceptions getting the types in an assembly
    $types = [System.AppDomain]::CurrentDomain.GetAssemblies()
    | ForEach-Object -Process { try { $_.GetTypes() } catch {} }
    | Where-Object -FilterScript {
      Test-DevXIsAstType -Type $_
    }
  }

  process {
    if (-not [string]::IsNullOrEmpty($Pattern)) {
      $types | Where-Object -FilterScript {
        $_.Name -match $Pattern
      }
    } elseif ($Name.Count -gt 0) {
      foreach ($n in $Name) {
        $types | Where-Object -FilterScript {
          $_.Name -in @($n, "${n}Ast")
        }
      }
    } else {
      $types
    }
  }

  end {

  }
}

function Get-EnumRegex {
  <#
    .SYNOPSIS
    Function synopsis.
  #>


  [CmdletBinding()]
  [OutputType([String])]
  param(
    [Parameter(Mandatory)]
    [ValidateScript({ $_.IsEnum })]
    [Type]
    $EnumType
  )

  begin {

  }

  process {
    "(?<$($EnumType.Name)>($($EnumType.GetEnumNames() -join '|')))"
  }

  end {

  }
}

Function New-AstPredicate {
  <#
    .SYNOPSIS
    Function synopsis.
  #>


  [CmdletBinding()]
  [OutputType([ScriptBlock])]
  Param (
    [Parameter()]
    [DevXAstTypeTransformAttribute()]
    [System.Type[]]
    $Type
  )

  begin {

  }

  process {
    foreach ($item in $Type) {
      {
        param([System.Management.Automation.Language.Ast]$AstObject)
        return ($AstObject -is $item)
      }.GetNewClosure()
    }
  }

  end {

  }
}

function New-SourceFile {
  <#
    .SYNOPSIS
    Function synopsis.
  #>


  [CmdletBinding()]
  [OutputType([SourceFile])]
  param(
    [Parameter()]
    [string]
    $NameSpace,

    [Parameter()]
    [string]
    $Path
  )

  begin {

  }

  process {
    [SourceFile]::new($NameSpace, $Path)
  }

  end {

  }
}

function New-SourceFolder {
  <#
    .SYNOPSIS
    Function synopsis.
  #>


  [CmdletBinding()]
  [OutputType([SourceFolder])]
  param(
    [Parameter()]
    [string]
    $NameSpace,

    [Parameter(Mandatory)]
    [string]
    $Path
  )

  begin {

  }

  process {
    if ($NameSpace) {
      [SourceFolder]::new($NameSpace, $Path)
    } else {
      [SourceFolder]::new($Path)
    }
  }

  end {

  }
}

function New-SourceReference {
  <#
    .SYNOPSIS
    Function synopsis.
  #>


  [CmdletBinding()]
  [OutputType([SourceReference])]
  param(
    [Parameter()]
    [SourceFile]
    $SourceFile,

    [Parameter()]
    [SourceFile[]]
    $Reference
  )

  begin {

  }

  process {
    if ($Reference) {
      [SourceReference]::new($SourceFile, $Reference)
    } else {
      [SourceReference]::new($SourceFile)
    }
  }

  end {

  }
}

function Resolve-NameSpace {
  <#
  .SYNOPSIS
  Function synopsis
  #>

  
  [CmdletBinding()]
  [OutputType([String])]
  param(
    [Parameter(Mandatory, ParameterSetName = 'ByTaxonomy')]
    [SourceCategory]
    $Category,
    
    [Parameter(Mandatory, ParameterSetName = 'ByTaxonomy')]
    [SourceScope]
    $Scope,
    
    [Parameter(ParameterSetName = 'ByPath')]
    [string]
    $ParentNameSpace,
    
    [Parameter(Mandatory, ParameterSetName = 'ByPath')]
    [string]
    $Path
  )
  
  begin {
    $baseFolderPattern = @(
      Get-EnumRegex -EnumType ([SourceScope])
      '\w*\.'
      Get-EnumRegex -EnumType ([SourceCategory])
      '\w*'
      '(?<Remaining>.+)?'
    ) -join ''
  }
  
  process {
    $ParentNameSpace = ''
    $childNameSpace  = ''
    
    # If a path is specified, we need to figure out child namespacing
    if ($Path) {
      <#
        Turn the path containing the file into a period-separated string to avoid future path
        separator munging/checking for xplat concerns.
      #>

      $definitionFolder = Get-Item -Path $Path
      | Select-Object -ExpandProperty PSIsContainer
      | ForEach-Object {
        ($_ ? $Path : (Split-Path -Parent -Path $Path)) -replace '(\\|\/)', '.'
      }
      <#
        When there's no parent namespace, we need to figure out the category and scope to build the
        parent namespace and collect any remaining folder segments as child namespace segments.
      #>

      if ([string]::IsNullOrEmpty($ParentNameSpace)) {
        $sourceRelativeFolder = ($definitionFolder -split 'Source')[-1].Trim('.')
        if ($sourceRelativeFolder -match $baseFolderPattern) {
          $Category = [SourceCategory].GetEnumNames() | Where-Object {
            $Matches.SourceCategory -like "$_*"
          }
          $Scope = [SourceScope].GetEnumNames() | Where-Object {
            $Matches.SourceScope -like "$_*"
          }
          $childNameSpace = $Matches.Remaining
        }
      } else {
        <#
          When we know the parent, we just want everything after it. When parent is the base (only
          has two segments, Category.Scope) we want to split on Category. Otherwise, split on the
          last segment.
        #>

        $parentSegments   = ($ParentNameSpace -split '\.')
        $splittingSegment = $parentSegments.Count -eq 2 ? $parentSegments[0] : $parentSegments[-1]
        $childNameSpace   = $definitionFolder -split $splittingSegment
        | Select-Object -Skip 1
        | Join-String -Separator '.'
      }
    }
    
    if ([string]::IsNullOrEmpty($ParentNameSpace)) {
      $ParentNameSpace = switch ($Category) {
        ArgumentCompleter { "ArgumentCompleters.$Scope" }
        Class             { "Classes.$Scope" }
        Enum              { "Enums.$Scope" }
        Function          { "Functions.$Scope" }
        Format            { "Formats.$Scope" }
        Task              { "Tasks.$Scope" }
        Type              { "Types.$Scope" }
      }
    }
    
    if ($Path) {
      return $ParentNameSpace + $ChildNameSpace
    } else {
      return $ParentNameSpace
    }
  }
  
  end {
    
  }
}

Function Test-DevXIsAstType {
  <#
    .SYNOPSIS
    Determines if a type is an AST type.
  #>


  [CmdletBinding()]
  [OutputType([bool])]
  Param(
    [Parameter()]
    [System.Type]
    $Type
  )

  begin {

  }

  process {
    $inNamespace = $Type.NameSpace -eq 'System.Management.Automation.Language'
    $endsInAst   = $Type.Name -match 'Ast$'

    return ($inNamespace -and $endsInAst)
  }

  end {

  }
}

#endregion Functions.Private