pslint.psm1

function pslint
{
    <#
    .SYNOPSIS
    Performance-focused PowerShell linter for analyzing scripts.
 
    .DESCRIPTION
    Analyzes a PowerShell script for performance issues. Supports PowerShell Core and Windows PowerShell.
 
    .PARAMETER Path
    The path to the script to analyze.
 
    .PARAMETER ScriptBlock
    The script block to analyze.
 
    .EXAMPLE
    Analyze a file:
    pslint -Path ".\your-script.ps1"
 
    Analyze a script block:
    $sb = { Write-Host "test" }
    pslint -ScriptBlock $sb
 
    .NOTES
            Author : @Calvindd2f
            Site : https://app-support.com
            File Name : pslint
            Version : 1.0
    #>

    [Alias('Scan-PowerShellScriptAdvanced')]
    [CmdletBinding()]
    PARAM (
        [Parameter(ParameterSetName = 'Path')]
        [ValidateScript({ $_ -match '\.ps1$' })]
        [string]
        $Path,

        [Parameter(ParameterSetName = 'ScriptBlock')]
        [scriptblock]
        $ScriptBlock
    )

    BEGIN
    {
        if ($PSCmdlet.ParameterSetName -eq 'Path')
        {
            if (-not (Test-Path $Path))
            {
                throw "File not found: $Path"
            }
            $parseErrors = $null
            $tokens = $null
            $ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$parseErrors)

            if ($parseErrors)
            {
                throw "Parse errors encountered: $($parseErrors -join "`n")"
            }
        }
        else
        {
            if ($null -eq $ScriptBlock)
            {
                throw 'ScriptBlock cannot be null'
            }
            $ast = $ScriptBlock.Ast
        }

        class CodeAnalysisResults
        {
            [System.Collections.Generic.List[object]]$OutputSuppression
            [System.Collections.Generic.List[object]]$ArrayAddition
            [System.Collections.Generic.List[object]]$StringAddition
            [System.Collections.Generic.List[object]]$LargeFileProcessing
            [System.Collections.Generic.List[object]]$LargeCollectionLookup
            [System.Collections.Generic.List[object]]$WriteHostUsage
            [System.Collections.Generic.List[object]]$LargeLoops
            [System.Collections.Generic.List[object]]$RepeatedFunctionCalls
            [System.Collections.Generic.List[object]]$CmdletPipelineWrapping
            [System.Collections.Generic.List[object]]$DynamicObjectCreation

            CodeAnalysisResults()
            {
                $this.OutputSuppression = $this.InitializeList()
                $this.ArrayAddition = $this.InitializeList()
                $this.StringAddition = $this.InitializeList()
                $this.LargeFileProcessing = $this.InitializeList()
                $this.LargeCollectionLookup = $this.InitializeList()
                $this.WriteHostUsage = $this.InitializeList()
                $this.LargeLoops = $this.InitializeList()
                $this.RepeatedFunctionCalls = $this.InitializeList()
                $this.CmdletPipelineWrapping = $this.InitializeList()
                $this.DynamicObjectCreation = $this.InitializeList()
            }

            [System.Collections.Generic.List[object]] InitializeList()
            {
                return [System.Collections.Generic.List[object]]::new()
            }

            [void] ClearLists()
            {
                $this.OutputSuppression.Clear()
                $this.ArrayAddition.Clear()
                $this.StringAddition.Clear()
                $this.LargeFileProcessing.Clear()
                $this.LargeCollectionLookup.Clear()
                $this.WriteHostUsage.Clear()
                $this.LargeLoops.Clear()
                $this.RepeatedFunctionCalls.Clear()
                $this.CmdletPipelineWrapping.Clear()
                $this.DynamicObjectCreation.Clear()
            }
        }
    }

    PROCESS
    {
        $results = [CodeAnalysisResults]::new()

        function Test-NodeSafely
        {
            param(
                [Parameter(Mandatory)]
                [System.Management.Automation.Language.Ast]$Node,
                [Parameter(Mandatory)]
                [scriptblock]$Condition
            )

            try
            {
                return (& $Condition $Node)
            }
            catch
            {
                Write-Verbose "Error checking node: $_"
                return $false
            }
        }

        # Check for Output Suppression
        $ast.FindAll({
                param($node)
                Test-NodeSafely -Node $node -Condition {
                    param($n)
                ($n -is [System.Management.Automation.Language.AssignmentStatementAst] -and
                    $null -ne $n.Right -and
                    $n.Right -is [System.Management.Automation.Language.VariableExpressionAst] -and
                    $n.Right.VariablePath.UserPath -eq 'null') -or
                ($n -is [System.Management.Automation.Language.CommandAst] -and
                    $n.Redirections.Count -gt 0 -and
                    $null -ne $n.Redirections[0] -and
                    $n.Redirections[0].ToString() -eq ">$null") -or
                ($n -is [System.Management.Automation.Language.CommandExpressionAst] -and
                    $null -ne $n.Expression -and
                    $n.Expression -is [System.Management.Automation.Language.TypeExpressionAst] -and
                    $n.Expression.TypeName.Name -eq 'void') -or
                ($n -is [System.Management.Automation.Language.PipelineAst] -and
                    $n.PipelineElements.Count -gt 0 -and
                    $null -ne $n.PipelineElements[-1].CommandElements -and
                    $n.PipelineElements[-1].CommandElements.Count -gt 0 -and
                    $n.PipelineElements[-1].CommandElements[-1].Value -eq 'Out-Null')
                }
            }, $true) | Where-Object { $null -ne $_ } | ForEach-Object { $results.OutputSuppression.Add($_) }

        # Check for ArrayAddition
        $ast.FindAll({
                param($node)
                Test-NodeSafely -Node $node -Condition {
                    param($n)
                ($n -is [System.Management.Automation.Language.AssignmentStatementAst] -and
                    $n.Operator -eq 'Equals' -and
                    $null -ne $n.Right -and
                    $n.Right -is [System.Management.Automation.Language.ArrayExpressionAst]) -or
                ($n -is [System.Management.Automation.Language.InvokeMemberExpressionAst] -and
                    $null -ne $n.Member -and
                    $n.Member.Value -eq 'Add') -or
                ($n -is [System.Management.Automation.Language.AssignmentStatementAst] -and
                    $n.Operator -eq 'PlusEquals')
                }
            }, $true) | Where-Object { $null -ne $_ } | ForEach-Object { $results.ArrayAddition.Add($_) }

        # Check for StringAddition
        <# Find StringBuilder usage
        $ast.FindAll({
                param($node)
                Test-NodeSafely -Node $node -Condition {
                    param($n)
                $node -is [System.Management.Automation.Language.TypeExpressionAst] -and
                $node.TypeName.FullName -eq 'System.Text.StringBuilder'
                }, $true } ) | Where-Object { $null -ne $_ } | ForEach-Object { $results.StringAddition.Add($_) }
 
        # Find Join operator usage
        $ast.FindAll({
                param($node)
                Test-NodeSafely -Node $node -Condition {
                    param($n)
                $node -is [System.Management.Automation.Language.BinaryExpressionAst] -and
                $node.Operator -eq 'Join'
                }, $true } ) | Where-Object { $null -ne $_ } | ForEach-Object { $results.StringAddition.Add($_) }
 
        #>

        # Find += operator usage with strings
        $ast.FindAll({
                param($node)
                Test-NodeSafely -Node $node -Condition {
                    param($n)
                    $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and
                    $node.Operator -eq 'PlusEquals'
                }, $true } ) | Where-Object { $null -ne $_ } | ForEach-Object { $results.StringAddition.Add($_) }

        # Find string format usage (-f operator)
        $ast.FindAll({
                param($node)
                Test-NodeSafely -Node $node -Condition {
                    param($n)
                    $node -is [System.Management.Automation.Language.BinaryExpressionAst] -and
                    $node.Operator -eq 'Format'
                }, $true } ) | Where-Object { $null -ne $_ } | ForEach-Object { $results.StringAddition.Add($_) }

        # Find string concatenation using + operator
        $ast.FindAll({
                param($node)
                Test-NodeSafely -Node $node -Condition {
                    param($n)
                    $node -is [System.Management.Automation.Language.BinaryExpressionAst] -and
                    $node.Operator -eq 'Plus' -and
            ($node.Left -is [System.Management.Automation.Language.StringConstantExpressionAst] -or
                    $node.Right -is [System.Management.Automation.Language.StringConstantExpressionAst])
                }, $true } ) | Where-Object { $null -ne $_ } | ForEach-Object { $results.StringAddition.Add($_) }

        # Find subexpression usage in strings
        $ast.FindAll({
                param($node)
                Test-NodeSafely -Node $node -Condition {
                    param($n)
                    $node -is [System.Management.Automation.Language.ExpandableStringExpressionAst] -and
                    $node.NestedExpressions.Count -gt 0
                }, $true } ) | Where-Object { $null -ne $_ } | ForEach-Object { $results.StringAddition.Add($_) }

        # Check for Large File Processing
        $ast.FindAll({
                param($node)
                Test-NodeSafely -Node $node -Condition {
                    param($n)
                ($n -is [System.Management.Automation.Language.CommandAst] -and
                    $n.CommandElements[0].Value -eq 'Get-Content') -or
                ($n -is [System.Management.Automation.Language.TypeExpressionAst] -and
                    $n.TypeName.Name -eq 'StreamReader') -or
                ($n -is [System.Management.Automation.Language.InvokeMemberExpressionAst] -and
                    $n.Expression.TypeName.Name -eq 'File' -and
                    $n.Member.Value -eq 'ReadLines')
                }
            }, $true) | ForEach-Object { $results.LargeFileProcessing.Add($_) }

        # Check for LargeCollectionLookup
        $ast.FindAll({
                param($node)
                Test-NodeSafely -Node $node -Condition {
                    param($n)
                    $n -is [System.Management.Automation.Language.HashtableAst]
                }
            }, $true) | ForEach-Object { $results.LargeCollectionLookup.Add($_) }

        # Check for WriteHostUsage
        $ast.FindAll({
                param($node)
                Test-NodeSafely -Node $node -Condition {
                    param($n)
                    $n -is [System.Management.Automation.Language.CommandAst] -and
                    $n.CommandElements[0].Value -eq 'Write-Host'
                }
            }, $true) | ForEach-Object { $results.WriteHostUsage.Add($_) }

        <# Check for WriteHostUsage
        $ast.FindAll({
                param($node)
                Test-NodeSafely -Node $node -Condition {
                    param($n)
                    $n -is [System.Management.Automation.Language.CommandAst] -and
                    $n.CommandElements[0].Value -eq '[console]::writeline'
                }
            }, $true) | ForEach-Object { $results.WriteHostUsage.Add($_) }#>


        # CHeck for LargeLoops
        $ast.FindAll({
                param($node)
                Test-NodeSafely -Node $node -Condition {
                    param($n)
                ($n -is [System.Management.Automation.Language.ForStatementAst] -or
                    $n -is [System.Management.Automation.Language.WhileStatementAst] -or
                    $n -is [System.Management.Automation.Language.DoWhileStatementAst] -or
                    $n -is [System.Management.Automation.Language.ForEachStatementAst]) -and
                    $node.Body.Extent.EndLineNumber - $node.Body.Extent.StartLineNumber > 15
                }
            }, $true) | ForEach-Object { $results.LargeLoops.Add($_) }

        # Check for RepeatedFunctionCalls
        $ast.FindAll({
                param($node)
                Test-NodeSafely -Node $node -Condition {
                    param($n)
                    $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
                    $n.Body.Extent.Text -match 'for\s*\('
                }
            }, $true) | ForEach-Object { $results.RepeatedFunctionCalls.Add($_) }

        # Check for CmdletPipelineWrapping
        $ast.FindAll({
                param($node)
                Test-NodeSafely -Node $node -Condition {
                    param($n)
                    $n -is [System.Management.Automation.Language.PipelineAst] -and
                    $n.PipelineElements.Count -gt 2
                }
            }, $true) | ForEach-Object { $results.CmdletPipelineWrapping.Add($_) }

        # Check for DynamicObjectCreation
        $ast.FindAll({
                param($node)
                Test-NodeSafely -Node $node -Condition {
                    param($n)
                ($n -is [System.Management.Automation.Language.ConvertExpressionAst] -and
                    $n.Type.TypeName.Name -eq 'pscustomobject') -or
                ($n -is [System.Management.Automation.Language.CommandAst] -and
                    $n.CommandElements[0].Value -eq 'Add-Member') -or
                ($n -is [System.Management.Automation.Language.MemberExpressionAst] -and
                    $n.Member.Value -eq 'Properties' -and
                    $n.Expression.TypeName.Name -eq 'PSObject')
                }
            }, $true) | ForEach-Object { $results.DynamicObjectCreation.Add($_) }
    }

    END
    {
        $report = [ordered]@{
            Summary    = @{
                TotalIssues = 0
                Categories  = @{}
            }
            Details    = [ordered]@{}
            Timestamp  = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
            ScriptPath = if ($Path) { $Path } else { "ScriptBlock Analysis" }
        }

        $results | Get-Member -MemberType Property | ForEach-Object {
            $categoryName = $_.Name
            $issues = $results.$categoryName

            if ($issues.Count -eq 0) { return }

            $report.Summary.Categories[$categoryName] = $issues.Count
            $report.Summary.TotalIssues += $issues.Count

            $report.Details[$categoryName] = @(
                foreach ($issue in $issues)
                {
                    @{
                        Line       = $issue.Extent.StartLineNumber
                        Text       = $issue.Extent.Text.Trim()
                        Suggestion = switch ($categoryName)
                        {
                            'OutputSuppression' { 'Consider using [void] for better performance' }
                            'ArrayAddition' { 'Consider using ArrayList or Generic List for better performance' }
                            'StringAddition' { 'Consider using -Join or String Buider for better performance' }
                            'LargeFileProcessing' { 'Consider using System.IO.StreamReader for large files' }
                            'LargeCollectionLookup' { 'Consider using Dictionary<TKey,TValue> for large collections' }
                            'WriteHostUsage' { "Consider using Write-Information, Write-Output or if you are a real CHAD - [console]::writeline(`$message)" }
                            'LargeLoops' { 'Consider breaking down large loops or using .NET methods' }
                            'RepeatedFunctionCalls' { 'Consider caching function results' }
                            'CmdletPipelineWrapping' { 'Consider reducing pipeline complexity' }
                            'DynamicObjectCreation' { 'Consider using classes or structured objects' }
                            default { 'Review for potential optimization' }
                        }
                    }
                }
            )
        }

        $isCI = [bool]$env:CI
        if ($isCI)
        {
            foreach ($category in $report.Details.Keys)
            {
                foreach ($issue in $report.Details[$category])
                {
                    Write-Output "::warning file=$($report.ScriptPath),line=$($issue.Line)::[$category] $($issue.Suggestion)"
                }
            }

            $report.Summary | ConvertTo-Json -Depth 10
        }
        else
        {
            Write-Output "`n=== PowerShell Performance Analysis Report ==="
            Write-Output "Script: $($report.ScriptPath)"
            Write-Output "Time: $($report.Timestamp)"
            Write-Output "`nSummary:"
            Write-Output "Total Issues Found: $($report.Summary.TotalIssues)"

            foreach ($category in $report.Details.Keys)
            {
                $issueCount = $report.Summary.Categories[$category]
                if ($issueCount -gt 0)
                {
                    Write-Output "`n== $category ($issueCount issues) =="
                    foreach ($issue in $report.Details[$category])
                    {
                        Write-Output " Line $($issue.Line):"
                        Write-Output " Code: $($issue.Text)"
                        Write-Output " Suggestion: $($issue.Suggestion)"
                    }
                }
            }
        }

        [System.GC]::Collect()
        [System.GC]::WaitForPendingFinalizers()
    }
}