PS.Utils.psm1


#region PS.Utils.AST

function Get-PSUtilAST {
    [CmdletBinding(
        DefaultParameterSetName = 'File'
    )]
    Param(

        [Parameter(
            ParameterSetName = 'File',
            Mandatory,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName
        )]
        [Alias('FullName')]
        [String]
        $FilePath,

        [Parameter(
            ParameterSetName = 'Code',
            Mandatory,
            ValueFromPipeline
        )]
        [string]
        $Code,

        [Parameter(ParameterSetName = 'Code')]
        [ArgumentCompleter({

            Param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)

            $collection = [PSObject].Assembly.GetTypes().Where{$_.Name.EndsWith('Ast')}.Name | Sort-Object | Where-Object { $_ -like "$wordToComplete*" }
            
            $collection | Foreach-Object { 
                [System.Management.Automation.CompletionResult]::new($_, $_, "ParameterValue", $_)
            }

        })]
        $AstType = '*',

        [Switch]
        $NoRecursion

    )

    BEGIN {

        $predicate = {
            param($astObject) $astObject.GetType().Name -like $AstType
        }

    }  

    PROCESS {

        $errors = $null
        
        $ast = switch ($PSCmdlet.ParameterSetName) {
            'File' { [System.Management.Automation.Language.Parser]::ParseFile( $FilePath, [ref]$null, [ref]$errors) }
            'Code' { [System.Management.Automation.Language.Parser]::ParseInput($Code,     [ref]$null, [ref]$errors) }
        }
        
        
        if ($errors) { 
            throw [System.InvalidCastException]::new("Submitted text could not be converted to PowerShell because it contains syntax errors: $($errors | Out-String)")
        }
        
        $ast.FindAll($predicate, !$NoRecursion) | Add-Member -MemberType ScriptProperty -Name Type -Value { $this.GetType().Name } -PassThru -Force

    }

}

function Get-PSUtilASTClass {
    [CmdletBinding(
        DefaultParameterSetName = 'File'
    )]
    Param(

        [Parameter(
            ParameterSetName = 'File',
            Mandatory,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName
        )]
        [Alias('FullName')]
        [String]
        $FilePath,

        [Parameter(
            ParameterSetName = 'Code',
            Mandatory,
            ValueFromPipeline
        )]
        [string]
        $Code,

        [Switch]
        $NoRecursion

    )

    BEGIN {

        $predicate = {

            param($astObject)

            $astObject -is [System.Management.Automation.Language.TypeDefinitionAst] -and $astObject.IsClass
        
        }

    }  

    PROCESS {

        $errors = $null
        
        $ast = switch ($PSCmdlet.ParameterSetName) {
            'File' { [System.Management.Automation.Language.Parser]::ParseFile( $FilePath, [ref]$null, [ref]$errors) }
            'Code' { [System.Management.Automation.Language.Parser]::ParseInput($Code,     [ref]$null, [ref]$errors) }
        }
        
        
        if ($errors) { 
            throw [System.InvalidCastException]::new("Submitted text could not be converted to PowerShell because it contains syntax errors: $($errors | Out-String)")
        }
        
        $ast.FindAll($predicate, !$NoRecursion) | Add-Member -MemberType ScriptProperty -Name Type -Value { $this.GetType().Name } -PassThru -Force

    }

}

function Get-PSUtilASTEnum {
    [CmdletBinding(
        DefaultParameterSetName = 'File'
    )]
    Param(

        [Parameter(
            ParameterSetName = 'File',
            Mandatory,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName
        )]
        [Alias('FullName')]
        [String]
        $FilePath,

        [Parameter(
            ParameterSetName = 'Code',
            Mandatory,
            ValueFromPipeline
        )]
        [string]
        $Code,

        [Switch]
        $NoRecursion

    )

    BEGIN {

        $predicate = {

            param($astObject)

            $astObject -is [System.Management.Automation.Language.TypeDefinitionAst] -and $astObject.IsEnum

        }

    }  

    PROCESS {

        $errors = $null
        
        $ast = switch ($PSCmdlet.ParameterSetName) {
            'File' { [System.Management.Automation.Language.Parser]::ParseFile( $FilePath, [ref]$null, [ref]$errors) }
            'Code' { [System.Management.Automation.Language.Parser]::ParseInput($Code,     [ref]$null, [ref]$errors) }
        }
        
        if ($errors) { 
            throw [System.InvalidCastException]::new("Submitted text could not be converted to PowerShell because it contains syntax errors: $($errors | Out-String)")
        }
        
        $ast.FindAll($predicate, !$NoRecursion) | Add-Member -MemberType ScriptProperty -Name Type -Value { $this.GetType().Name } -PassThru -Force

    }

}

function Get-PSUtilASTFunctionDefinition {
    [CmdletBinding(
        DefaultParameterSetName = 'File'
    )]
    Param(

        [Parameter(
            ParameterSetName = 'File',
            Mandatory,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName
        )]
        [Alias('FullName')]
        [String]
        $FilePath,

        [Parameter(
            ParameterSetName = 'Code',
            Mandatory,
            ValueFromPipeline
        )]
        [string]
        $Code,

        [Switch]
        $NoRecursion

    )

    BEGIN {

        $predicate = { param($astObject) $astObject.GetType().Name -eq 'FunctionDefinitionAst' }

    }  

    PROCESS {

        $errors = $null
        
        $ast = switch ($PSCmdlet.ParameterSetName) {
            'File' { [System.Management.Automation.Language.Parser]::ParseFile( $FilePath, [ref]$null, [ref]$errors) }
            'Code' { [System.Management.Automation.Language.Parser]::ParseInput($Code,     [ref]$null, [ref]$errors) }
        }
        
        
        if ($errors) { 
            throw [System.InvalidCastException]::new("Submitted text could not be converted to PowerShell because it contains syntax errors: $($errors | Out-String)")
        }
        
        $ast.FindAll($predicate, !$NoRecursion) | Add-Member -MemberType ScriptProperty -Name Type -Value { $this.GetType().Name } -PassThru -Force

    }

}

function Get-PSUtilASTTypeNameList {
    [CmdletBinding()]
    Param()

    PROCESS {

        [PSObject].Assembly.GetTypes().Where{$_.Name.EndsWith('Ast')}.Name | Sort-Object

    }

}

#endregion PS.Utils.AST

#region PS.Utils.Build

function Get-PSUtilFunctionInformation {
    <#
    .SYNOPSIS
        Finds function definitions in a PowerShell file and lists unique commands used in each.

    .DESCRIPTION
        Parses a PowerShell script or module file, locates every FunctionDefinitionAst,
        and for each function collects the unique command/cmdlet names referenced inside it.

    .PARAMETER Path
        Path to the PowerShell file to analyze.

    .PARAMETER Exclude
        One or more wildcard patterns matched against the file name.
        Matching files are skipped. Defaults to '*.Tests.ps1' so Pester
        test files are ignored.

    .EXAMPLE
        Get-FunctionData -Path '.\src\PS.Utils\PS.Utils.psm1'

        Returns one object per function with Name and Commands properties.

    .EXAMPLE
        Get-ChildItem -Path . -Recurse -Filter *.ps*1 |
            Get-FunctionData

        Processes script/module files while skipping *.Tests.ps1 by default.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('FullName', 'PSPath', 'LiteralPath')]
        [ValidateNotNullOrEmpty()]
        [string]
        $Path,

        [Parameter()]
        [Alias('ExcludePattern')]
        [string[]]
        $Exclude = @('*.Tests.ps1')
    )

    begin {
        $commandAstType = [System.Management.Automation.Language.CommandAst]
        $functionAstType = [System.Management.Automation.Language.FunctionDefinitionAst]
    }

    process {
        $resolvedPath = $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
        $fileName = [System.IO.Path]::GetFileName($resolvedPath)

        foreach ($pattern in $Exclude) {
            if ([string]::IsNullOrWhiteSpace($pattern)) {
                continue
            }

            if ($fileName -like $pattern) {
                Write-Verbose "Skipping excluded file '$resolvedPath' (matched '$pattern')."
                return
            }
        }

        if (-not (Test-Path -LiteralPath $resolvedPath -PathType Leaf)) {
            $PSCmdlet.WriteError(
                [System.Management.Automation.ErrorRecord]::new(
                    [System.IO.FileNotFoundException]::new("File not found: $resolvedPath"),
                    'FileNotFound',
                    [System.Management.Automation.ErrorCategory]::ObjectNotFound,
                    $resolvedPath
                )
            )
            return
        }

        $tokens = $null
        $errors = $null
        $ast = [System.Management.Automation.Language.Parser]::ParseFile(
            $resolvedPath,
            [ref]$tokens,
            [ref]$errors
        )

        if ($errors -and $errors.Count -gt 0) {
            foreach ($parseError in $errors) {
                $PSCmdlet.WriteWarning("Parse error in '${resolvedPath}': $($parseError.Message)")
            }
        }

        $functionAsts = $ast.FindAll(
            { param($node) $node -is $functionAstType },
            $true
        )

        foreach ($functionAst in $functionAsts) {
            $commandAsts = $functionAst.FindAll(
                { param($node) $node -is $commandAstType },
                $true
            )

            $commands = [System.Collections.Generic.HashSet[string]]::new(
                [System.StringComparer]::OrdinalIgnoreCase
            )

            foreach ($commandAst in $commandAsts) {
                $commandName = $commandAst.GetCommandName()
                if (-not [string]::IsNullOrWhiteSpace($commandName)) {
                    [void]$commands.Add($commandName)
                }
            }

            [pscustomobject]@{
                PSTypeName = 'PS.Utils.FunctionData'
                Name       = $functionAst.Name
                Path       = $resolvedPath
                StartLine  = $functionAst.Extent.StartLineNumber
                EndLine    = $functionAst.Extent.EndLineNumber
                IsFilter   = [bool]$functionAst.IsFilter
                IsWorkflow = [bool]$functionAst.IsWorkflow
                Commands   = @($commands | Sort-Object)
            }
        }
    }
}

#endregion PS.Utils.Build

#region PS.Utils.CommentHelp

function Convert-PSUtilCommentHelpToInside {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$FilePath,
        
        [Parameter(Mandatory = $true)]
        [string]$FunctionName
    )

    # 1. Locate targets
    $Target = Get-AstHelpPosition -FilePath $FilePath | Where-Object { $_.FunctionName -eq $FunctionName }
    if ($null -eq $Target -or $Target.HelpPosition -ne 'Outside') {
        Write-Warning "Function '$FunctionName' help is already Inside or does not exist."
        return
    }

    $RawContent = [System.IO.File]::ReadAllText($FilePath)
    $CommentText = $Target.CommentToken.Text

    # 2. Extract bounds
    $CommentStart = $Target.CommentToken.Extent.StartOffset
    $CommentLength = $Target.CommentToken.Extent.EndOffset - $CommentStart

    # Find the precise opening brace position inside the body AST block
    $BodyStartOffset = $Target.FuncAst.Body.Extent.StartOffset

    # 3. Surgical String Manipulation
    # Remove the comment text completely from outside the header
    $StrippedContent = $RawContent.Remove($CommentStart, $CommentLength)

    # Re-calculate index alignment since deleting elements shifted the remaining text backwards
    $ShiftedBodyStart = $BodyStartOffset - $CommentLength

    # Safely insert the help comment inside the open brace block (adding a clean indentation spacing newline)
    $FinalContent = $StrippedContent.Insert($ShiftedBodyStart + 1, "`n $CommentText")

    [System.IO.File]::WriteAllText($FilePath, $FinalContent)
    Write-Verbose "Moved comment-based help for '$FunctionName' to the INSIDE."
}

function Convert-PSUtilCommentHelpToOutside {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$FilePath,
        
        [Parameter(Mandatory = $true)]
        [string]$FunctionName
    )

    # 1. Locate the targets
    $Target = Get-AstHelpPosition -FilePath $FilePath | Where-Object { $_.FunctionName -eq $FunctionName }
    if ($null -eq $Target -or $Target.HelpPosition -ne 'Inside') {
        Write-Warning "Function '$FunctionName' help is already Outside or does not exist."
        return
    }

    $RawContent = [System.IO.File]::ReadAllText($FilePath)
    $CommentText = $Target.CommentToken.Text

    # 2. Extract bounds
    $CommentStart = $Target.CommentToken.Extent.StartOffset
    $CommentLength = $Target.CommentToken.Extent.EndOffset - $CommentStart
    $FuncStart = $Target.FuncAst.Extent.StartOffset

    # 3. Surgical String Manipulation
    # Remove the comment from its original inside location
    $StrippedContent = $RawContent.Remove($CommentStart, $CommentLength)

    # Re-insert the comment cleanly directly above the function keyword header
    # Adjusting position slightly if stripping changed the index layout
    $FinalIndex = if ($CommentStart -lt $FuncStart) { $FuncStart - $CommentLength } else { $FuncStart }
    $FinalContent = $StrippedContent.Insert($FinalIndex, "$CommentText`n")

    [System.IO.File]::WriteAllText($FilePath, $FinalContent)
    Write-Verbose "Moved comment-based help for '$FunctionName' to the OUTSIDE."
}

function Get-PSUtilCommentHelpInfo {
    [CmdletBinding()]
    Param(

        [Parameter(
            Mandatory,
            ValueFromPipeline
        )]
        [System.Management.Automation.Language.FunctionDefinitionAst]
        $FuntionDefinitionAst

    )

    PROCESS {

        $predicate = {
            param($astObject) $astObject.GetType().Name -like 'FunctionDefinitionAst'
        }

        $commentHelp = $FuntionDefinitionAst.GetHelpContent()

        $commentHelpType = if ($commentHelp) {

            $errors = $null
            $ast    = [System.Management.Automation.Language.Parser]::ParseInput($FuntionDefinitionAst.Extent.Text, [ref]$null, [ref]$errors)
            $result = $ast.FindAll($predicate, $false)

            if ($result.GetHelpContent()) {
                'Internal'
            } else {
                'External'
            }

        }
        else {
            "None"
        }

        [PSCustomObject]@{
            FunctionName    = $function.Name
            CommentHelpType = $commentHelpType
            CommentHelp     = $commentHelp
        }

    }

}

#endregion PS.Utils.CommentHelp