core/private/Get-JaxPsakeTaskInfoByParsing.ps1

function Get-JaxPsakeTaskInfoByParsing {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string] $FilePath,
        [switch] $NoCache,
        # Internal: tracks files already visited so recursive Include
        # resolution cannot loop. Callers should leave this empty; the function
        # repopulates it on each recursive call.
        [Parameter(DontShow = $true)]
        [System.Collections.Generic.HashSet[string]] $VisitedFiles
    )

    $commonParams = Get-JaxCommonParameters -BoundParameters $PSBoundParameters
    Write-Debug "FUNC: $($MyInvocation.MyCommand.Name) Args: $($PSBoundParameters | ConvertTo-Json -Depth 1 -Compress -WarningAction SilentlyContinue)"

    if (-not (Test-Path -Path $FilePath -PathType Leaf)) {
        return @{
            TaskNames  = @()
            Parameters = @()
        }
    }

    if ($null -eq $VisitedFiles) {
        $VisitedFiles = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    }
    try {
        $normalizedPath = [System.IO.Path]::GetFullPath($FilePath)
    } catch {
        $normalizedPath = $FilePath
    }
    if (-not $VisitedFiles.Add($normalizedPath)) {
        return @{
            TaskNames  = @()
            Parameters = @()
        }
    }

    $contentRaw = Get-Content -Path $FilePath -Raw -ErrorAction Stop

    $taskNames = @()
    $scriptAst = [System.Management.Automation.Language.Parser]::ParseInput($contentRaw, [ref]$null, [ref]$null)
    $commands = $scriptAst.FindAll({
            param($ast)
            $ast -is [System.Management.Automation.Language.CommandAst] -and
            $ast.CommandElements.Count -gt 1 -and
            $ast.CommandElements[0] -is [System.Management.Automation.Language.StringConstantExpressionAst] -and
            $ast.CommandElements[0].Value -ieq 'Task'
        }, $true)

    foreach ($command in $commands) {
        $nameElement = $command.CommandElements[1]
        if ($nameElement -isnot [System.Management.Automation.Language.StringConstantExpressionAst]) {
            continue
        }
        $taskName = $nameElement.Value.Trim()
        if (-not [string]::IsNullOrWhiteSpace($taskName) -and ($taskNames -notcontains $taskName)) {
            $taskNames += $taskName
        }
    }

    # ─── Follow psake `Include` directives so tasks defined in partial files
    # are discoverable too. Without this, splitting a large psakefile across
    # several files (for example, an environment psakefile that includes
    # app-psakefile.ps1 and site-psakefile.ps1) silently hides those tasks
    # from `jax run -only <TaskName>` even though psake itself loads them.
    #
    # Resolution covers common psakefile patterns:
    #
    # Include "../common/psakefile.ps1" (string literal)
    # Include "$PSScriptRoot/tasks-engine-switch.ps1" (expandable string)
    # Include "$gitRoot/code/...." (expandable string,
    # var bound earlier
    # in this file)
    # Include (Join-Path $PSScriptRoot "file.ps1")
    # Include (Join-Path $gitRoot "code/.../psakefile.ps1")
    #
    # `$PSScriptRoot` is the directory of this script. Other variables are
    # resolved by walking the top-level assignments in the same file and
    # statically evaluating the simple Join-Path / string-literal expressions
    # the existing scripts use. Anything more dynamic falls through and is
    # silently skipped — same fallback as before.
    $fileDir = Split-Path -Parent $FilePath
    $varScope = @{ PSScriptRoot = $fileDir }
    $assignments = $scriptAst.FindAll({
            param($ast)
            $ast -is [System.Management.Automation.Language.AssignmentStatementAst] -and
            $ast.Operator -eq 'Equals' -and
            $ast.Left -is [System.Management.Automation.Language.VariableExpressionAst]
        }, $false)
    $resolveExpr = $null
    $resolveExpr = {
        param($expr)
        if ($null -eq $expr) { return $null }
        if ($expr -is [System.Management.Automation.Language.CommandExpressionAst]) {
            return & $resolveExpr $expr.Expression
        }
        if ($expr -is [System.Management.Automation.Language.PipelineAst]) {
            if ($expr.PipelineElements.Count -ne 1) { return $null }
            return & $resolveExpr $expr.PipelineElements[0]
        }
        if ($expr -is [System.Management.Automation.Language.ParenExpressionAst]) {
            return & $resolveExpr $expr.Pipeline
        }
        if ($expr -is [System.Management.Automation.Language.StringConstantExpressionAst]) {
            return $expr.Value
        }
        if ($expr -is [System.Management.Automation.Language.ExpandableStringExpressionAst]) {
            $result = $expr.Value
            foreach ($nested in @($expr.NestedExpressions)) {
                if ($nested -is [System.Management.Automation.Language.VariableExpressionAst]) {
                    $varName = $nested.VariablePath.UserPath
                    if ($varScope.ContainsKey($varName)) {
                        $result = $result -replace [regex]::Escape("`$$varName"), [string]$varScope[$varName]
                    } else {
                        return $null
                    }
                } else {
                    return $null
                }
            }
            return $result
        }
        if ($expr -is [System.Management.Automation.Language.VariableExpressionAst]) {
            $name = $expr.VariablePath.UserPath
            if ($varScope.ContainsKey($name)) { return [string]$varScope[$name] }
            return $null
        }
        if ($expr -is [System.Management.Automation.Language.CommandAst] -and
            $expr.CommandElements.Count -ge 3 -and
            $expr.CommandElements[0] -is [System.Management.Automation.Language.StringConstantExpressionAst] -and
            $expr.CommandElements[0].Value -ieq 'Join-Path') {
            $base = & $resolveExpr $expr.CommandElements[1]
            $tail = & $resolveExpr $expr.CommandElements[2]
            if ([string]::IsNullOrWhiteSpace($base) -or [string]::IsNullOrWhiteSpace($tail)) {
                return $null
            }
            return Join-Path $base $tail
        }
        return $null
    }
    foreach ($assignment in $assignments) {
        $name = $assignment.Left.VariablePath.UserPath
        if ([string]::IsNullOrWhiteSpace($name)) { continue }
        if ($varScope.ContainsKey($name)) { continue }
        $value = & $resolveExpr $assignment.Right
        if (-not [string]::IsNullOrWhiteSpace($value)) {
            $varScope[$name] = $value
        }
    }

    $includeCommands = $scriptAst.FindAll({
            param($ast)
            $ast -is [System.Management.Automation.Language.CommandAst] -and
            $ast.CommandElements.Count -gt 1 -and
            $ast.CommandElements[0] -is [System.Management.Automation.Language.StringConstantExpressionAst] -and
            $ast.CommandElements[0].Value -ieq 'Include'
        }, $true)

    foreach ($command in $includeCommands) {
        $arg = $command.CommandElements[1]
        $includePath = & $resolveExpr $arg

        if ([string]::IsNullOrWhiteSpace($includePath)) {
            # Unresolvable Include (dynamic variable, computed path). Skip
            # silently — the task definitions in that file just won't be
            # discoverable via parsing.
            continue
        }

        if (-not [System.IO.Path]::IsPathRooted($includePath)) {
            $includePath = Join-Path $fileDir $includePath
        }
        try {
            $includePath = [System.IO.Path]::GetFullPath($includePath)
        } catch {
            # Leave path as-is; Test-Path inside the recursive call will reject
            # it if invalid.
        }

        $recursed = Get-JaxPsakeTaskInfoByParsing -FilePath $includePath -NoCache:$NoCache -VisitedFiles $VisitedFiles
        foreach ($name in $recursed.TaskNames) {
            if (-not [string]::IsNullOrWhiteSpace($name) -and ($taskNames -notcontains $name)) {
                $taskNames += $name
            }
        }
    }

    $parameters = @()
    $propertiesMatch = [regex]::Match($contentRaw, '(?ms)properties\s*\{(.*?)\}')
    if ($propertiesMatch.Success) {
        $block = $propertiesMatch.Groups[1].Value
        $lines = $block -split "(\r\n|\n)"
        foreach ($line in $lines) {
            $trimmed = ($line -replace '#.*$', '').Trim()
            if ([string]::IsNullOrWhiteSpace($trimmed)) {
                continue
            }
            $paramMatch = [regex]::Match($trimmed, '^\s*(?:\[(?<type>[^\]]+)\])?\s*\$(?<name>[\w-]+)\s*(?:=\s*(?<default>.+?))?\s*(?:,)?$')
            if (-not $paramMatch.Success) {
                continue
            }
            $paramName = $paramMatch.Groups['name'].Value
            if ([string]::IsNullOrWhiteSpace($paramName)) {
                continue
            }
            $typeName = if ($paramMatch.Groups['type'].Success) { $paramMatch.Groups['type'].Value.Trim() } else { 'System.Object' }
            $defaultValue = $null
            if ($paramMatch.Groups['default'].Success) {
                $rawValue = $paramMatch.Groups['default'].Value.Trim().TrimEnd(',')
                if ($rawValue -match "^'.*'$" -or $rawValue -match '^".*"$') {
                    $rawValue = $rawValue.Trim("'").Trim('"')
                }
                switch ($rawValue.ToLower()) {
                    '$true' { $defaultValue = $true }
                    '$false' { $defaultValue = $false }
                    '$null' { $defaultValue = $null }
                    default { $defaultValue = $rawValue }
                }
            }
            $parameters += [ordered]@{
                Name         = $paramName
                Type         = $typeName
                Description  = "$paramName set in '$FilePath' properties block"
                DefaultValue = $defaultValue
            }
        }
    }

    return @{
        TaskNames  = $taskNames
        Parameters = $parameters
    }
}