Modules/businessdev.ALbuild.Apps/Private/Get-BcAlSourceModel.ps1

function Get-BcAlSourceModel {
    <#
    .SYNOPSIS
        Parses an AL source file into the statements, procedures and unmeasurable bodies code coverage needs.
 
    .DESCRIPTION
        Business Central reports a statement once, on the line where it starts, and only for methods the
        debugger can see. This parser mirrors that, so the denominator holds exactly the lines BC can report.
        The rules are calibrated against a real BC 28.5 run of test/fixtures/coverage-patterns, in which every
        statement is executed: the parser must return exactly the lines BC reported.
 
        * A statement runs from its first line until a line ends in ';' or a block keyword (then, do, of,
          else, begin, repeat, end) outside any parenthesis. Only its first line counts; the other lines are
          continuation lines and map to it (StatementOf).
        * A case label list ends in a single ':' and may span lines ('a',<newline>'b':). It is not a
          statement. A statement on the label's last line ('y': exit(3);) counts on that line.
        * Lines made only of block keywords (begin, end, else, then, do, of, repeat - also 'else begin',
          'end else') are not statements. 'end else if X then' is: BC reports the condition.
        * Bodies of [NonDebuggable] methods run but are never reported. Their lines are returned separately
          (UnmeasuredLines) and never in ExecutableLines.
        * With -Symbols, lines in a non-compiled #if branch are skipped. An #if expression this does not
          understand keeps its branch active.
 
        One AL object per file is assumed, matching Get-BcAlObjectMap.
 
    .PARAMETER Path
        The .al file to analyse.
 
    .PARAMETER Symbols
        The preprocessor symbols the file was compiled with. Omitted means no branch is skipped.
 
    .OUTPUTS
        PSCustomObject: ExecutableLines (int[]), UnmeasuredLines (int[]), StatementOf (hashtable continuation
        line -> statement line), Procedures (Name, Kind, Line, EndLine, NonDebuggable, Lines).
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Path,
        [string[]] $Symbols = @()
    )

    $exec = [System.Collections.Generic.SortedSet[int]]::new()
    $unmeasured = [System.Collections.Generic.SortedSet[int]]::new()
    $statementOf = @{}
    $procedures = [System.Collections.Generic.List[object]]::new()
    $model = { [PSCustomObject]@{ ExecutableLines = [int[]]@($exec); UnmeasuredLines = [int[]]@($unmeasured); StatementOf = $statementOf; Procedures = @($procedures) } }

    $lines = @(Get-Content -LiteralPath $Path -ErrorAction SilentlyContinue)
    if ($lines.Count -eq 0) { return (& $model) }

    # String literals, then quoted identifiers, are blanked so no keyword or bracket inside them is read.
    $maskLiterals = {
        param([string] $text)
        $masked = [regex]::Replace($text, "'(?:[^']|'')*'", "''")
        [regex]::Replace($masked, '"(?:[^"]|"")*"', '""')
    }

    $useSymbols = @($Symbols).Count -gt 0
    $branches = [System.Collections.Generic.List[hashtable]]::new()
    $branchActive = { if ($branches.Count -eq 0) { $true } else { [bool] $branches[$branches.Count - 1].Active } }
    $evalCondition = {
        param([string] $expression)
        $e = "$expression".Trim()
        if ($e -match '^not\s+([A-Za-z_][A-Za-z0-9_]*)$') { return -not ($Symbols -contains $Matches[1]) }
        if ($e -match '^([A-Za-z_][A-Za-z0-9_]*)$') { return ($Symbols -contains $Matches[1]) }
        return $null
    }

    $leadingKeywordsRx = [regex]::new('^(?:(?:begin|end|else|then|do|of|repeat)\b;?\s*)+')
    $terminatorRx = [regex]::new('(?:;|(?<![A-Za-z0-9_."])(?:then|do|of|else|begin|repeat|end))$')
    $labelColonRx = [regex]::new('(?<!:):(?![:=])')
    $controlRx = [regex]::new('(?i)^(?:field|action|part|systempart|usercontrol|dataitem|column|value|systemaction|fileuploadaction|customaction)\s*\(\s*(?:\d+\s*;\s*)?(?<name>"(?:[^"]|"")*"|[A-Za-z0-9_]+)')
    $headerRx = [regex]::new('(?i)^(?:local\s+|internal\s+|protected\s+)*(?<kind>procedure|trigger)\s+(?<name>"(?:[^"]|"")*"|[A-Za-z0-9_]+)')

    $inBody = $false
    $depth = 0
    $pendingHeader = $null
    $inBlockComment = $false
    $parenDepth = 0
    $pendingNonDebuggable = $false
    $current = $null          # procedure whose body is being read
    $stmtStart = 0            # first line of the statement still open, 0 = none
    $braceDepth = 0
    $controls = [System.Collections.Generic.List[hashtable]]::new()

    $addLine = {
        param([int] $n)
        if ($current.NonDebuggable) { [void]$unmeasured.Add($n) } else { [void]$exec.Add($n) }
        [void]$current.LineSet.Add($n)
    }
    $removeLine = {
        param([int] $n)
        [void]$unmeasured.Remove($n); [void]$exec.Remove($n); [void]$current.LineSet.Remove($n)
        foreach ($k in @($statementOf.Keys)) { if ($statementOf[$k] -eq $n) { $statementOf.Remove($k) } }
    }

    for ($i = 0; $i -lt $lines.Count; $i++) {
        $lineNo = $i + 1
        $raw = $lines[$i]
        $scan = (& $maskLiterals $raw).Trim()

        if ($inBlockComment) {
            if ($scan -match '\*/') { $inBlockComment = $false; $scan = ($scan -replace '^.*?\*/', '').Trim() } else { continue }
        }
        $scan = ($scan -replace '/\*.*?\*/', ' ').Trim()
        if ($scan -match '/\*') { $inBlockComment = $true; $scan = ($scan -replace '/\*.*$', '').Trim() }
        if ($scan -eq '' -or $scan.StartsWith('//')) { continue }
        $code = ($scan -replace '//.*$', '').Trim()
        if ($code -eq '') { continue }
        $lower = $code.ToLowerInvariant()

        if ($lower.StartsWith('#')) {
            if ($lower -match '^#\s*if\b') {
                $parentActive = & $branchActive
                $condition = if ($code -match '^#\s*if\s+(.+)$') { $Matches[1] } else { '' }
                $value = & $evalCondition $condition
                $isActive = if ($null -eq $value) { $true } else { [bool] $value }
                $branches.Add(@{ Active = ($parentActive -and $isActive); Taken = ($parentActive -and $isActive); ParentActive = $parentActive })
            }
            elseif ($lower -match '^#\s*else\b') {
                if ($branches.Count -gt 0) {
                    $frame = $branches[$branches.Count - 1]
                    $frame.Active = ($frame.ParentActive -and -not $frame.Taken)
                    $frame.Taken = $true
                }
            }
            elseif ($lower -match '^#\s*endif\b') {
                if ($branches.Count -gt 0) { $branches.RemoveAt($branches.Count - 1) }
            }
            continue
        }
        if ($useSymbols -and -not (& $branchActive)) { continue }

        if (-not $inBody) {
            # Structure outside bodies: remember the enclosing control so a trigger can be named after it.
            $cm = $controlRx.Match((($raw -replace '//.*$', '').Trim()))
            if ($cm.Success) { $controls.Add(@{ Name = $cm.Groups['name'].Value.Trim('"'); Depth = $braceDepth; Entered = $false }) }
            $braceDepth += ([regex]::Matches($code, '\{')).Count - ([regex]::Matches($code, '\}')).Count
            while ($controls.Count -gt 0) {
                $top = $controls[$controls.Count - 1]
                if ($braceDepth -gt $top.Depth) { $top.Entered = $true; break }
                if ($top.Entered) { $controls.RemoveAt($controls.Count - 1) } else { break }
            }

            # Only an attribute outside a header and a body applies to a method (a variable may carry it too).
            if (-not $pendingHeader -and $lower -match '^\[nondebuggable(\(\s*\))?\]') { $pendingNonDebuggable = $true; continue }

            $hm = $headerRx.Match($code)
            if ($hm.Success) {
                $name = $hm.Groups['name'].Value.Trim('"')
                $kind = $hm.Groups['kind'].Value.ToLowerInvariant()
                if ($kind -eq 'trigger' -and $controls.Count -gt 0) { $name = '{0} - {1}' -f $controls[$controls.Count - 1].Name, $name }
                $pendingHeader = @{ Name = $name; Kind = $kind; Line = $lineNo; NonDebuggable = $pendingNonDebuggable }
                $pendingNonDebuggable = $false
                $parenDepth = 0
                continue
            }
            if ($pendingHeader -and $lower -match '^begin\b') {
                $inBody = $true; $depth = 1; $stmtStart = 0; $parenDepth = 0
                $current = @{ Name = $pendingHeader.Name; Kind = $pendingHeader.Kind; Line = $pendingHeader.Line; NonDebuggable = $pendingHeader.NonDebuggable; LineSet = [System.Collections.Generic.SortedSet[int]]::new() }
                $pendingHeader = $null
            }
            continue
        }

        $opens = ([regex]::Matches($lower, '(?<![A-Za-z0-9_])begin(?![A-Za-z0-9_])')).Count
        # A case block is closed by 'end' as well; '.Case' and quoted names are not the keyword.
        $opens += ([regex]::Matches($lower, '(?<![A-Za-z0-9_."])case(?![A-Za-z0-9_])')).Count
        $closes = ([regex]::Matches($lower, '(?<![A-Za-z0-9_])end(?![A-Za-z0-9_])')).Count

        $lead = $leadingKeywordsRx.Match($lower)
        $rest = if ($lead.Success) { $lower.Substring($lead.Length).Trim() } else { $lower }
        # A closing or branching keyword ends whatever statement was open (the last one may omit its ';').
        if ($lead.Success -or $rest -match '^until\b') { $stmtStart = 0 }

        if ($rest -ne '' -and $parenDepth -eq 0 -and $rest -notmatch '^[{}();]+$') {
            if ($stmtStart -eq 0) { $stmtStart = $lineNo; & $addLine $lineNo }
            elseif ($stmtStart -ne $lineNo) { $statementOf[$lineNo] = $stmtStart }

            $label = $labelColonRx.Match($rest)
            if ($label.Success) {
                # Everything so far was a case label. A statement after the colon starts on this line.
                & $removeLine $stmtStart
                if ($statementOf.ContainsKey($lineNo)) { $statementOf.Remove($lineNo) }
                $tail = $rest.Substring($label.Index + 1).Trim()
                $tailRest = $tail.Substring($leadingKeywordsRx.Match($tail).Length).Trim()
                if ($tailRest -ne '') { $stmtStart = $lineNo; & $addLine $lineNo } else { $stmtStart = 0 }
            }
        }
        elseif ($rest -ne '' -and $stmtStart -ne 0 -and $stmtStart -ne $lineNo) {
            $statementOf[$lineNo] = $stmtStart
        }

        $parenDepth += ([regex]::Matches($code, '\(')).Count - ([regex]::Matches($code, '\)')).Count
        if ($parenDepth -lt 0) { $parenDepth = 0 }
        if ($parenDepth -eq 0 -and ($rest -eq '' -or $terminatorRx.IsMatch($lower))) { $stmtStart = 0 }

        $depth += $opens - $closes
        if ($depth -le 0) {
            $procedures.Add([PSCustomObject]@{
                    Name          = $current.Name
                    Kind          = $current.Kind
                    Line          = $current.Line
                    EndLine       = $lineNo
                    NonDebuggable = [bool]$current.NonDebuggable
                    Lines         = [int[]]@($current.LineSet)
                })
            $inBody = $false; $depth = 0; $parenDepth = 0; $stmtStart = 0; $current = $null
        }
    }

    return (& $model)
}